Skip to content

Workflow — state machines & workflows

TL;DR — Le composant Workflow modélise les transitions d'état d'un objet métier (commande, article, déploiement). Deux types : state machine (1 seul état, transitions exclusives, type state_machine) et workflow Petri (plusieurs places simultanées, type workflow). Tu déclares places + transitions en YAML, ajoutes des guards (Expression Language), un marking store, et tu écoutes les events workflow.X.guard|leave|transition|enter|entered|completed|announce pour piloter la mécanique. Audit trail natif, intégration Doctrine simple.

🧠 Mental model

STATE MACHINE (1 active state):

    ┌────────┐  submit   ┌──────────┐  approve  ┌──────────┐
    │ draft  │──────────▶│ pending  │──────────▶│ approved │
    └────────┘           └──────────┘           └─────┬────┘
                              │                      │ publish
                              │ reject               ▼
                              ▼                ┌──────────┐
                         ┌──────────┐          │ published │
                         │ rejected │          └──────────┘
                         └──────────┘

WORKFLOW (Petri net — multiple active places):

  [created] ──t1──▶ ●[stock_reserved]

                    │     ●[payment_charged]
                    │     │
                    └─────┴── t_ship ──▶ [shipped]

  Both stock_reserved AND payment_charged required to fire t_ship.


  Workflow events dispatched per transition:
  ─────────────────────────────────────────────
  workflow.guard           ← veto with $event->setBlocked(true)
  workflow.leave           ← leaving FROM places
  workflow.transition      ← transition itself
  workflow.enter           ← entering TO places (before persist)
  workflow.entered         ← after persist
  workflow.completed       ← whole transition done
  workflow.announce        ← list now-enabled transitions

Analogie : un workflow, c'est un GPS pour ton entité. À chaque instant, le GPS sait où tu es (places actives), où tu peux aller (transitions enabled), et bloque les routes interdites (guards). Une state machine = autoroute (un seul tronçon à la fois). Un workflow = système de canaux avec écluses en parallèle.

🛠️ Code minimal

yaml
# config/packages/workflow.yaml
framework:
    workflows:
        order_workflow:
            type: state_machine        # or 'workflow' for Petri net
            audit_trail:
                enabled: true
            marking_store:
                type: method
                property: status        # Order::getStatus() / setStatus()
            supports:
                - App\Entity\Order
            initial_marking: cart
            places:
                - cart
                - placed
                - paid
                - shipped
                - delivered
                - cancelled
            transitions:
                place_order:
                    from: cart
                    to:   placed
                    guard: "is_authenticated() and subject.getTotal() > 0"
                pay:
                    from: placed
                    to:   paid
                ship:
                    from: paid
                    to:   shipped
                    guard: "subject.getShippingAddress() !== null"
                deliver:
                    from: shipped
                    to:   delivered
                cancel:
                    from: [cart, placed, paid]
                    to:   cancelled
php
// src/Entity/Order.php (excerpt)
#[ORM\Entity]
class Order
{
    #[ORM\Column(length: 32)]
    private string $status = 'cart';

    public function getStatus(): string { return $this->status; }
    public function setStatus(string $s): void { $this->status = $s; }
    // ...
}
php
// src/Controller/OrderController.php
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;

public function pay(
    Order $order,
    PayRequest $req,                                 // DTO résolu/validé en amont
    #[Target('orderWorkflow')] WorkflowInterface $workflow,
    EntityManagerInterface $em,
): Response {
    if (!$workflow->can($order, 'pay')) {
        return $this->json(['blocked' => $this->blockers($workflow, $order, 'pay')], 409);
    }

    try {
        // Le 3e argument = "context" propagé dans tous les events de la transition,
        // récupérable via $event->getContext(). Idéal pour transporter payment_id, user_id…
        $workflow->apply($order, 'pay', ['payment_id' => $req->paymentId]);
        $em->flush();
    } catch (NotEnabledTransitionException $e) {
        // Race : un autre process a déjà payé entre le can() et le apply().
        return $this->json(['error' => $e->getMessage()], 409);
    }

    return $this->json(['status' => $order->getStatus()]);
}

private function blockers(WorkflowInterface $w, $subject, string $t): array
{
    return array_map(
        fn($b) => $b->getMessage(),
        iterator_to_array($w->buildTransitionBlockerList($subject, $t))
    );
}
php
// src/EventListener/OrderWorkflowListener.php
use Symfony\Component\Workflow\Attribute\AsTransitionListener;
use Symfony\Component\Workflow\Attribute\AsGuardListener;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Event\TransitionEvent;
use Symfony\Component\Workflow\TransitionBlocker;

final class OrderWorkflowListener
{
    public function __construct(
        private MessageBusInterface $bus,
        private LoggerInterface $logger,
        private StockChecker $stock,
    ) {}

    #[AsGuardListener(workflow: 'order_workflow', transition: 'ship')]
    public function blockShipIfNoStock(GuardEvent $e): void
    {
        /** @var Order $order */
        $order = $e->getSubject();
        if (!$this->stock->isAvailable($order)) {
            // setBlocked(bool, ?string) ne porte qu'un message. Pour attacher un
            // CODE (utile pour mapper les blockers vers des messages i18n côté API),
            // ajoute un TransitionBlocker explicite — son 2e arg EST le code.
            $e->addTransitionBlocker(
                new TransitionBlocker('No stock available', 'order.ship.no_stock')
            );
        }
    }

    #[AsTransitionListener(workflow: 'order_workflow', transition: 'pay')]
    public function onPaid(TransitionEvent $e): void
    {
        $order = $e->getSubject();
        $this->bus->dispatch(new SendInvoiceEmail($order->getId()));
        $this->logger->info('order.paid', ['id' => $order->getId()]);
    }
}

🧭 Comment un staff engineer raisonne

Le workflow n'est PAS la source de vérité — la place l'est

Le piège mental n°1 : croire que $workflow->apply() est la transaction métier. Faux. Le composant Workflow ne fait que muter une chaîne (status/etat) en validant qu'une arête du graphe existe. La vérité métier vit dans : (1) la propriété marking persistée par Doctrine, (2) l'audit trail. Le graphe lui-même est sans état (stateless), partagé entre tous les sujets, et reconstruit à chaque requête. Conséquence directe : toute la cohérence transactionnelle est ta responsabilité, pas celle du composant. apply() + side-effects + flush() doivent être atomiques ou idempotents — sinon tu as un workflow qui ment.

Le diagramme de décision : state_machine vs workflow vs Saga vs enum

BesoinOutilPourquoi
2-3 états, aucune règleenum + propriétéLe Workflow est du sur-engineering. Un enum Status: string typé suffit.
Cycle de vie, 1 état actif à la fois, guardstype: state_machineTransitions exclusives, marking = string simple. 90 % des cas.
Places parallèles requises avant une transition (AND-join)type: workflow (Petri)Plusieurs places actives, marking = array. Ex : tests_ok AND review_ok AND scan_okdeploy.
Transaction distribuée entre services/bounded contextsSaga + MessengerPas de marking partagé possible ; compensation explicite (rollback métier). Le Workflow reste local à un agrégat.
Règles dynamiques data-driven (pricing, éligibilité)Rule engine dédiéLe Workflow modélise des transitions, pas une arborescence de règles.

Heuristique : choisis state_machine par défaut. Tu ne passes à workflow (Petri) que le jour où tu as un vrai AND-join — plusieurs conditions parallèles qui doivent toutes être satisfaites. Si tu n'as que des OR (plusieurs chemins menant à un état), une state machine suffit.

Le cycle d'events est ton vrai point d'extension

L'ordre est garanti et c'est ce qui rend le composant puissant pour la prod :

can()/apply()
  └─ guard         (×N : un par paire from→transition)  ← VETO ici, AVANT toute mutation
apply() seulement :
  └─ leave         (sur chaque place quittée)
  └─ transition    (la transition elle-même)
  └─ [MUTATION du marking + écriture audit_trail]
  └─ enter         (sur chaque place atteinte — AVANT flush Doctrine)
  └─ entered       (après mutation, toujours AVANT flush)
  └─ completed     (transition terminée)
  └─ announce      (déclenche les guards des transitions désormais possibles)

Règles staff :

  • Guard = lecture seule, rapide, idempotent. JAMAIS d'effet de bord (il est rejoué par can(), announce, l'UI…). Un guard qui écrit en base est un bug.
  • transition / enter / entered = mutation locale au sujet OK, mais rien n'est encore flushé. Ne déclenche pas d'appel HTTP externe ici en supposant que l'état est persisté.
  • completed = l'endroit canonique pour l'audit applicatif et le dispatch de messages async (le marking est en mémoire, le flush suit). Pour un side-effect qui DOIT voir l'état persisté, utilise un message Messenger dispatché ici mais consommé après commit (DispatchAfterCurrentBusStamp ou transport doctrine qui partage la transaction).
  • announce se déclenche en cascade et rejoue les guards des transitions nouvellement activées — si tes guards sont lents, announce multiplie le coût. Désactive-le (announce: false n'existe pas : ne dispatch pas si tu ne l'écoutes pas — il est gratuit tant que personne n'écoute).

Marking store : method vs property vs enum

php
// Cas enum (Symfony 6.1+) : MethodMarkingStore fonctionne si tes setters/getters
// exposent la *valeur* string de l'enum, pas l'enum lui-même.
enum OrderStatus: string {
    case Cart = 'cart';
    case Placed = 'placed';
    case Paid = 'paid';
}

#[ORM\Column(enumType: OrderStatus::class)]
private OrderStatus $status = OrderStatus::Cart;

// Le Workflow manipule des strings ('cart', 'placed'…). Adapter au bord :
public function getStatus(): string { return $this->status->value; }
public function setStatus(string $s): void { $this->status = OrderStatus::from($s); }

type: method (défaut) appelle getStatus()/setStatus(). type: property accède directement à la propriété (utile pour un store custom). Pour un workflow Petri (places multiples), le marking est un array<string,int> : ta colonne doit être json, et les accesseurs typés array.

🎯 Patterns courants

  1. Guards centralisés — toute logique d'autorisation/règles métier dans des workflow.X.guard listeners. Le controller se contente de can() / apply().
  2. Side-effects via Messenger — sur workflow.entered.paid, dispatch SendInvoice, NotifyWarehouse, UpdateAnalytics. Découple le workflow des actions externes.
  3. Audit trail → table — listener sur workflow.completed qui écrit (subject_id, transition, from, to, user_id, at) dans order_history. Permet event sourcing light et debug.
  4. Workflow versionné — quand les règles changent, ajoute une colonne workflow_version. Un workflow order_v2 peut coexister, et les vieilles commandes restent sur order_v1.
  5. State machine pour content moderationdraft → submitted → in_review → approved/rejected → published. Chaque transition guard sur le rôle (is_granted('ROLE_MODERATOR')).
  6. Workflow Petri pour deploy pipeline — places parallèles tests_passed, security_scan_passed, code_review_done, toutes requises avant transition deploy_to_prod. Naturellement parallèle.

🔄 Versions

  • 5.4 : workflow:dump génère DOT/Mermaid/PlantUML. WorkflowEvents constants. MethodMarkingStore + MultipleStateMarkingStore.
  • 6.4 (LTS) : #[AsTransitionListener], #[AsGuardListener], #[AsCompletedListener], #[AsEnteredListener], #[AsLeaveListener], #[AsAnnounceListener] (6.3+). Plus besoin de subscribers verbeux. WorkflowInterface::getEnabledTransition() (singulier). Target attribute pour DI ciblée (#[Target('orderWorkflow')]).
  • 7.x : nettoyage des deprecations (anciens noms d'événements). Mieux intégré au DI (autowiring par nom de workflow). WorkflowDumpCommand enrichi.

Note historique : avant 4.x, on devait câbler à la main des subscribers. Aujourd'hui, l'attribut PHP rend tout déclaratif.

⚠️ Pitfalls

  1. State machine vs Workflow confondus — utiliser type: workflow pour ce qui est une simple state machine génère un Petri inutilement complexe (places multiples possibles). Choisis state_machine par défaut.
  2. Persistance oubliée$workflow->apply() modifie la propriété, mais ne flushe pas. Toujours $em->flush() après, ou utiliser un workflow.entered listener qui flush.
  3. Marking store inadaptémethod (default) attend des setters/getters. Si tu as une enum status, utilise un transformer ou le property store avec adapter.
  4. Guards lents — un guard qui hit la DB est appelé sur chaque can() et dans workflow.guard event. Cache le résultat ou repousse la vraie validation dans workflow.transition.
  5. Concurrent transitions — deux requêtes simultanées appellent apply('pay'). Sans verrouillage optimiste (Doctrine @Version) ou lock pessimiste, tu peux double-payer. Toujours combiner workflow + locking.
  6. Trop d'events dans un seul transitionworkflow.entered.paid qui dispatche 10 messages. Si la transition échoue à mi-chemin (exception), la moitié est partie. Solution : un seul event handler qui dispatche un seul Saga message coordinateur.
  7. YAML géant — 50 transitions dans un fichier = illisible. Splitter en plusieurs workflows ou utiliser PHP config pour la génération programmatique.
  8. Pas de workflow:dump — sans diagramme visualisable, l'équipe perd vite la vue d'ensemble. bin/console workflow:dump order_workflow --dump-format=mermaid à versionner dans le repo.

🧪 Testing

php
use Symfony\Component\Workflow\Definition;
use Symfony\Component\Workflow\StateMachine;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;

public function testCanPayPlacedOrder(): void
{
    $definition = (new DefinitionBuilder())
        ->addPlaces(['cart','placed','paid'])
        ->addTransition(new Transition('place_order', 'cart', 'placed'))
        ->addTransition(new Transition('pay', 'placed', 'paid'))
        ->setInitialPlaces('cart')
        ->build();

    $workflow = new StateMachine(
        $definition,
        new MethodMarkingStore(true, 'status')
    );

    $order = new Order();
    $this->assertTrue($workflow->can($order, 'place_order'));
    $workflow->apply($order, 'place_order');
    $this->assertSame('placed', $order->getStatus());
    $this->assertTrue($workflow->can($order, 'pay'));
}

Avec le container :

php
public function testGuardBlocksShipWithoutAddress(): void
{
    self::bootKernel();
    /** @var WorkflowInterface $workflow */
    $workflow = self::getContainer()->get('state_machine.order_workflow');

    $order = (new Order())->setStatus('paid'); // shipping_address = null
    $this->assertFalse($workflow->can($order, 'ship'));

    $blockers = $workflow->buildTransitionBlockerList($order, 'ship');
    $this->assertGreaterThan(0, \count(iterator_to_array($blockers)));
}

Test du listener via le dispatcher en mock + TransitionEvent construit à la main.

🎬 Cas d'usage concrets

Scénario 1 — Workflow d'état d'un dossier juridique

Le DMS du cabinet modélise l'état d'un dossier comme une machine à états formelle Symfony Workflow : BrouillonOuvertEnInstructionEnPlaidoirieEnDelibereJugeClotureArchive, avec des branches alternatives Suspendu et Abandonne accessibles depuis plusieurs états. Chaque transition exige des conditions précises (guards) : passage en EnPlaidoirie nécessite une date d'audience renseignée, passage en Cloture impose un jugement enregistré ou un protocole transactionnel signé. Les guards sont implémentés via listeners workflow.dossier.guard.{transition} qui consultent l'agrégat et lèvent un blocking si les invariants ne sont pas satisfaits. Les listeners workflow.dossier.entered.juge génèrent automatiquement la facture finale, ceux sur entered.archive déclenchent une migration vers stockage froid S3 Glacier. L'éditeur de workflow Symfony en HTML/CLI (bin/console workflow:dump dossier | dot -Tsvg) sert de documentation officielle livrée à l'audit de conformité.

Scénario 2 — Workflow de demande de crédit FinTech avec scoring asynchrone

Une FinTech française de crédit à la consommation modélise le parcours d'une demande de prêt : InitieeEnVerificationKYCEnScoringEnComitéRisque (si scoring borderline) → Acceptee ou RefuseeSignatureDecaissee ou Annulee. Les transitions critiques sont asynchrones : EnVerificationKYC déclenche un appel à Onfido via Messenger ; quand la réponse arrive (typiquement 30 s à 5 min), un handler applique la transition apply('kyc_validate', ...) ou apply('kyc_reject', ...). Le workflow utilise le marker store MethodMarkingStore directement sur l'entité DemandeCredit::$etat, sérialisé en base. Les listeners announce.* exposent les transitions disponibles dans l'API REST pour piloter dynamiquement l'UI : le bouton "valider le comité risque" n'apparaît au superviseur que si la transition valider_comite est can(). La traçabilité réglementaire (article L. 312-19 du Code de la consommation) impose un audit log de chaque transition incluant l'identité de l'opérateur, capturé par un listener générique workflow.demande.completed.

Scénario 3 — Workflow de validation de devis B2B e-commerce

Un site e-commerce B2B vendant du matériel professionnel propose pour les commandes > 5 000 € un parcours devis : BrouillonEnvoyeAuClientAccepteClient ou RefuseClientEnAttenteValidationCommerciale (si remise > 10 %) → ValideTransformeEnCommande. Le workflow type state_machine (un seul état à la fois) est appliqué via attribut sur l'entité Devis. Le passage en EnAttenteValidationCommerciale est conditionnel : un guard listener inspecte le taux de remise et bloque la transition valider directement si remise > 10 %, redirigeant vers escalader. La validation commerciale ouvre un écran dédié pour les directeurs commerciaux. Le workflow expose les transitions disponibles dans l'interface client via can(), ce qui permet d'afficher uniquement les boutons pertinents (par exemple "Accepter le devis" disparaît une fois accepté). Une commande app:devis:relancer-expires revisite quotidiennement les devis EnvoyeAuClient dépassant 30 jours et applique automatiquement expirer.

🛠️ Exemple end-to-end

Use case : workflow demande de crédit avec transitions asynchrones KYC et scoring, guards, et audit log.

php
<?php
// config/packages/workflow.yaml (extrait)
// framework:
//     workflows:
//         credit:
//             type: state_machine
//             marking_store:
//                 type: method
//                 property: etat
//             supports: [App\Domain\Credit\Entity\DemandeCredit]
//             initial_marking: initiee
//             places: [initiee, en_kyc, en_scoring, en_comite, acceptee, refusee, signature, decaissee, annulee]
//             transitions:
//                 lancer_kyc: { from: initiee, to: en_kyc }
//                 kyc_valider: { from: en_kyc, to: en_scoring }
//                 kyc_refuser: { from: en_kyc, to: refusee }
//                 scoring_ok: { from: en_scoring, to: acceptee }
//                 scoring_borderline: { from: en_scoring, to: en_comite }
//                 scoring_ko: { from: en_scoring, to: refusee }
//                 comite_valider: { from: en_comite, to: acceptee }
//                 comite_refuser: { from: en_comite, to: refusee }
//                 signer: { from: acceptee, to: signature }
//                 decaisser: { from: signature, to: decaissee }
//                 annuler: { from: [acceptee, signature], to: annulee }

// src/Domain/Credit/Entity/DemandeCredit.php
declare(strict_types=1);

namespace App\Domain\Credit\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
class DemandeCredit
{
    #[ORM\Id, ORM\Column(type: 'uuid')]
    private Uuid $id;

    #[ORM\Column(length: 32)]
    private string $etat = 'initiee';

    #[ORM\Column(type: 'integer')]
    private int $montantCents;

    #[ORM\Column(nullable: true)]
    private ?int $scoreRisque = null;

    public function __construct(int $montantCents)
    {
        $this->id = Uuid::v7();
        $this->montantCents = $montantCents;
    }

    public function getId(): Uuid { return $this->id; }
    public function getEtat(): string { return $this->etat; }
    public function setEtat(string $etat): void { $this->etat = $etat; }
    public function getMontantCents(): int { return $this->montantCents; }
    public function getScoreRisque(): ?int { return $this->scoreRisque; }
    public function attribuerScore(int $score): void { $this->scoreRisque = $score; }
}

// src/Application/Credit/Listener/ScoringTransitionListener.php
namespace App\Application\Credit\Listener;

use App\Domain\Credit\Entity\DemandeCredit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\WorkflowInterface;

final readonly class ScoringTransitionListener
{
    public function __construct(private WorkflowInterface $creditStateMachine) {}

    #[AsEventListener('workflow.credit.guard.scoring_ok')]
    public function guardScoringOk(GuardEvent $event): void
    {
        /** @var DemandeCredit $demande */
        $demande = $event->getSubject();
        if ($demande->getScoreRisque() === null || $demande->getScoreRisque() < 700) {
            $event->setBlocked(true, sprintf('Score insuffisant : %d', $demande->getScoreRisque() ?? 0));
        }
    }

    #[AsEventListener('workflow.credit.guard.scoring_borderline')]
    public function guardBorderline(GuardEvent $event): void
    {
        $demande = $event->getSubject();
        $score = $demande->getScoreRisque() ?? 0;
        if ($score < 500 || $score >= 700) {
            $event->setBlocked(true, 'Hors plage borderline (500-699)');
        }
    }
}

// src/Application/Credit/Handler/ScoringTermineHandler.php
namespace App\Application\Credit\Handler;

use App\Application\Credit\Message\ScoringTermine;
use App\Domain\Credit\Repository\DemandeCreditRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Workflow\WorkflowInterface;

#[AsMessageHandler]
final readonly class ScoringTermineHandler
{
    public function __construct(
        private DemandeCreditRepository $demandes,
        private WorkflowInterface $creditStateMachine,
        private EntityManagerInterface $em,
    ) {}

    public function __invoke(ScoringTermine $msg): void
    {
        $demande = $this->demandes->find($msg->demandeId) ?? throw new \DomainException('Demande introuvable');
        $demande->attribuerScore($msg->score);

        $transition = match (true) {
            $msg->score >= 700 => 'scoring_ok',
            $msg->score >= 500 => 'scoring_borderline',
            default => 'scoring_ko',
        };
        $this->creditStateMachine->apply($demande, $transition);
        $this->em->flush();
    }
}

🔁 Quand utiliser / éviter

Utiliser :

  • Toute entité avec un cycle de vie clair (order, ticket, content, deploy).
  • Quand les transitions ont des règles métier multiples (guards).
  • Quand l'audit trail est requis (qui a fait quoi, quand).
  • Pour expliciter le métier à des non-devs (le YAML/diagramme se lit en réunion).
  • Pour des pipelines avec steps parallèles → Petri net.

Éviter :

  • Pour 2-3 états triviaux (active/inactive) — un boolean suffit.
  • Pour une logique purement temporelle (utilise Scheduler).
  • Comme moteur de règles génériques (utilise un rule engine dédié).
  • Pour des transitions distribuées entre microservices (utilise Saga + Messenger).
  • Quand l'entité change de structure plus que d'état (modèle de domaine inadéquat).

🔭 Production : concurrence, observabilité, idempotence

Concurrence — le bug qui coûte de l'argent

Deux requêtes lisent Order(status=placed), les deux passent can('pay'), les deux apply('pay'). Sans garde-fou, double paiement. Trois lignes de défense, du plus léger au plus fort :

php
// 1) Verrou optimiste Doctrine — détecte le conflit au flush (OptimisticLockException).
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 0;

// 2) Verrou pessimiste — sérialise les transitions sur la même ligne.
$em->find(Order::class, $id, LockMode::PESSIMISTIC_WRITE);

// 3) Garde d'idempotence applicative — la transition est-elle déjà jouée ?
if (in_array('paid', $workflow->getMarking($order)->getPlaces(), true)) {
    return; // déjà payé : no-op, pas d'erreur
}

Le marking store ne verrouille rien. Le composant Workflow est concurrency-agnostic : il valide un graphe en mémoire. La concurrence est gérée à la couche persistance/transaction. Combine toujours Workflow + locking pour un agrégat à valeur monétaire.

Observabilité — un état n'est utile que s'il est mesurable

  • Audit trail natif (audit_trail.enabled: true) loggue chaque transition via Monolog (canal workflow). En prod, route ce canal vers un index dédié (Loki/ELK) avec subject_id, transition, from, to.
  • Métriques : un listener completed générique incrémente un compteur workflow_transition_total{workflow,transition,from,to} (Prometheus). Tu visualises le funnel : combien de placed atteignent paid, où ça coince.
  • Tracing : ouvre un span OpenTelemetry par apply(), attribut workflow.transition. Corrèle la transition avec l'appel KYC/paiement asynchrone qu'elle déclenche.
  • Diagramme versionné : bin/console workflow:dump order --dump-format=mermaid > docs/order-workflow.mmd en CI. Le diff du .mmd en PR rend visible toute modification du cycle de vie — un reviewer voit qu'une arête a bougé.

Idempotence des side-effects

completed peut être rejoué (retry Messenger, redéploiement, replay d'audit). Tout handler déclenché par une transition doit être idempotent : clé d'idempotence = (subject_id, transition). Un SendInvoiceEmail doit vérifier qu'aucune facture n'existe déjà pour cette commande avant d'émettre — sinon le client reçoit 3 factures le jour d'un incident de retry.

🏋️ Exercices

1. State machine de modération d'article (implement)

Objectif : modéliser draft → submitted → in_review → (approved | rejected) → published, avec guard de rôle sur chaque transition sensible. Indice/Solution : type: state_machine, marking sur Article::$status. Guard workflow.article.guard.publish qui bloque si !is_granted('ROLE_EDITOR'). Expose can() dans le contrôleur pour n'afficher que les boutons autorisés. Vérifie qu'un rejected peut repartir en draft (arête de retour).

2. Petri net de pipeline de déploiement (implement → parallèle)

Objectif : type: workflowdeploy_to_prod exige tests_passed AND security_scan_passed AND review_approved (places parallèles toutes actives). Indice/Solution : marking = colonne json, accesseurs typés array. Trois transitions indépendantes alimentent les trois places ; la transition finale a from: [tests_passed, security_scan_passed, review_approved]. Teste que can('deploy_to_prod') reste false tant qu'une seule place manque.

3. Audit trail applicatif + métriques (production-grade)

Objectif : un listener completed générique qui écrit (subject_id, transition, from, to, user_id, at) en table workflow_log ET incrémente un compteur Prometheus, pour TOUS les workflows. Indice/Solution : #[AsCompletedListener] sans transition: → écoute toutes les transitions du workflow nommé. Récupère from/to via $event->getTransition()->getFroms()/getTos(), l'utilisateur via Security, le contexte via $event->getContext(). Attention : le listener doit être tolérant à un Security sans user (console/Messenger).

4. Side-effect asynchrone idempotent post-commit (production-grade)

Objectif : sur entered.paid, envoyer la facture exactement une fois, même en cas de retry/redéploiement, et seulement après commit Doctrine. Indice/Solution : dispatch SendInvoice avec DispatchAfterCurrentBusStamp (ou transport doctrine partageant la transaction). Le handler vérifie l'unicité (order_id) via contrainte UNIQUE sur invoice + capture de UniqueConstraintViolationException → no-op. Teste le double-consume du même message.

5. Course au paiement (break-then-fix)

Objectif : reproduire un double apply('pay') concurrent qui double-paie, puis le corriger. Indice/Solution : deux process lisent placed, les deux can('pay') passent. Reproduis avec deux transactions parallèles (script ou parallel). Fix : #[ORM\Version] → la 2e flush() lève OptimisticLockException → 409. Vérifie qu'un seul paiement passe. Bonus : compare avec un lock pessimiste PESSIMISTIC_WRITE et discute le coût (contention vs retry).

6. Migration de workflow versionné sans casser les commandes en vol (break-then-fix)

Objectif : les règles changent (v2 ajoute une étape fraud_check entre placed et paid). Les commandes déjà en placed ne doivent pas se retrouver bloquées. Indice/Solution : colonne workflow_version. Deux workflows déclarés (order_v1, order_v2), supports distinct via guard sur la version, ou sélection du WorkflowInterface par un factory selon order.workflowVersion. Backfill optionnel : un command qui migre les placed v1 vers une place compatible v2. Le piège : un état de v1 absent de v2 fait planter getMarking()InvalidArgumentException (place inconnue).

🎤 En entretien

Q : Quelle est la différence entre une state machine et un workflow (Petri net) dans Symfony, et quand choisir l'un ? R : Une state machine a un seul état actif (marking = string) et des transitions exclusives ; un workflow Petri autorise plusieurs places actives simultanées (marking = array) et donc des AND-joins. On choisit workflow uniquement quand plusieurs conditions parallèles doivent toutes être satisfaites avant une transition (ex : tests + scan + review avant deploy) ; sinon state_machine par défaut.

Q : Pourquoi ne jamais mettre d'effet de bord dans un guard ? R : Le guard est rejoué hors transition — par can(), par l'event announce, par l'UI qui interroge les transitions disponibles. Il doit être idempotent et en lecture seule ; un guard qui écrit en base ou envoie un mail déclencherait des effets fantômes à chaque interrogation d'état.

Q : apply() a muté le statut mais la requête crashe avant le flush(). Que se passe-t-il, et comment garantir la cohérence avec les side-effects async ? R : La mutation Doctrine est rollbackée — l'entité revient à son état précédent en base, donc pas d'incohérence côté marking. Le danger vient des side-effects dispatchés trop tôt (dans transition/enter) qui partent même si le flush échoue. Solution : dispatcher les messages dans completed/entered avec DispatchAfterCurrentBusStamp (ou un transport partageant la transaction Doctrine) pour qu'ils ne soient émis qu'après commit, et rendre chaque handler idempotent.

Q : Deux requêtes concurrentes appellent apply('pay') sur la même commande. Le composant Workflow protège-t-il contre le double paiement ? R : Non — le composant est concurrency-agnostic, il valide juste un graphe en mémoire. La protection vient de la couche persistance : verrou optimiste (#[ORM\Version]OptimisticLockException au 2e flush) ou pessimiste (PESSIMISTIC_WRITE), plus une garde d'idempotence (if (marking déjà 'paid') return;). Workflow + locking, toujours ensemble pour un agrégat monétaire.

🔗 Liens

Bibliothèque tech perso — Achref