Dans beaucoup d’applications, c’est le flux métier qui fait la différence: validation de contenu, traitement d’une commande, intégration avec un prestataire, etc. Symfony Workflow offre une manière déclarative, testable et visualisable de piloter ces processus. Dans cet article, on va au-delà des bases pour modéliser des machines à états, sécuriser les transitions avec des guards, orchestrer des événements, persister les marquages avec Doctrine et générer des graphes exploitables par vos équipes.
State machine ou workflow : bien choisir
Symfony propose deux types de graphes:
- state_machine: un seul état actif à la fois. Idéal pour les statuts de commande, de facture, de ticket.
- workflow: plusieurs états actifs en parallèle. Utile pour les processus où plusieurs “pistes” avancent simultanément (ex: validations multilingues, approbations multi-équipes).
Conseil pratique:
- Choisissez state_machine par défaut. Basculez vers workflow seulement si vous avez réellement des états parallèles indépendants.
- Un nombre d’états/branches qui explose est souvent le signe d’un design à simplifier (ex: déporter des variantes dans la donnée, pas dans les places).
Modéliser un flux métier clair
Exemple: une commande e-commerce avec un flux simple et lisible.
## config/packages/workflows.yaml
framework:
workflows:
order:
type: state_machine
initial_marking: draft
supports:
- App\Entity\Order
marking_store:
type: single_state
property: status
places:
- draft
- validated
- paid
- shipped
- cancelled
transitions:
validate:
from: draft
to: validated
guard: "is_granted('ORDER_VALIDATE', subject) and (subject.total <= 10000 or is_granted('ROLE_FINANCE'))"
metadata:
label: "Valider la commande"
pay:
from: validated
to: paid
ship:
from: paid
to: shipped
cancel:
from: [draft, validated, paid]
to: cancelled
guard: "subject.canBeCancelled()"
metadata:
color: "#E55353"
Points clés:
- initial_marking définit l’état de départ.
- marking_store single_state persiste un statut unique (colonne status).
- metadata est utile pour la documentation/visualisation (labels, couleurs).
Guards: vos règles métier au bon endroit
Les guards conditionnent une transition. Ils peuvent s’appuyer sur:
- la sécurité (is_granted)
- des propriétés du sujet (subject.total)
- des helpers métiers (subject.canBeCancelled())
Bonnes pratiques:
- Gardez les guards déterministes et rapides. Pas d’appel réseau ici.
- Réservez les effets de bord (emails, webhooks) aux événements du workflow.
Exemple de guard avancé
guard: "is_granted('ORDER_VALIDATE', subject)
and (subject.total <= 10000 or is_granted('ROLE_FINANCE'))
and (subject.customerIsVerified())"
Cela combine autorisation, seuil financier et prérequis métier, tout en restant lisible.
Événements: brancher le workflow à votre SI
Chaque transition émet des événements auxquels vous pouvez souscrire:
- workflow.{name}.guard
- workflow.{name}.transition
- workflow.{name}.leave.{place}
- workflow.{name}.enter.{place}
- workflow.{name}.entered.{place}
- workflow.{name}.completed
- workflow.{name}.announce
Exemple: déclencher une capture de paiement et notifier un système externe lors de l’entrée dans paid.
<?php
// src/EventSubscriber/OrderWorkflowSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\EnteredEvent;
use Symfony\Component\Workflow\Event\TransitionEvent;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Message\CapturePaymentCommand;
use App\Service\ErpGateway;
final class OrderWorkflowSubscriber implements EventSubscriberInterface
{
public function __construct(
private MessageBusInterface $bus,
private ErpGateway $erp
) {}
public static function getSubscribedEvents(): array
{
return [
'workflow.order.entered.paid' => 'onPaid',
'workflow.order.transition.cancel' => 'onCancel',
];
// Ajoutez d'autres hooks si nécessaire
}
public function onPaid(EnteredEvent $event): void
{
$order = $event->getSubject();
// Orchestration asynchrone (voir notre article dédié sur Messenger)
$this->bus->dispatch(new CapturePaymentCommand($order->getId()));
}
public function onCancel(TransitionEvent $event): void
{
$order = $event->getSubject();
// Notifier l’ERP de l’annulation (idempotent côté ERP)
$this->erp->notifyCancellation($order->getReference());
}
}
Conseils:
- Favorisez l’asynchrone pour la robustesse (retries, timeouts, idempotence côté consommateur).
- Les Side effects n’appartiennent pas aux guards; utilisez les events entered/transition/completed.
Persistance avec Doctrine: simple et fiable
La persistance du marquage peut se faire:
- via une propriété (single_state/multiple_state)
- via un store Doctrine dédié (utile quand vous ne souhaitez pas exposer la propriété)
Pour un state_machine, une simple colonne string suffit.
<?php
// src/Entity/Order.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 20)]
private string $status = 'draft';
#[ORM\Column(length: 32, unique: true)]
private string $reference;
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 1;
public function canBeCancelled(): bool
{
// Règle métier simple
return !in_array($this->status, ['shipped']);
}
// getters/setters…
}
Application d’une transition dans un service ou contrôleur, avec verrouillage optimiste pour éviter les compétitions:
<?php
use Symfony\Component\Workflow\WorkflowInterface;
use Doctrine\ORM\EntityManagerInterface;
public function pay(Order $order, WorkflowInterface $orderStateMachine, EntityManagerInterface $em): void
{
if (!$orderStateMachine->can($order, 'pay')) {
throw new \LogicException('Transition pay non autorisée.');
}
$em->wrapInTransaction(function () use ($orderStateMachine, $order, $em) {
$orderStateMachine->apply($order, 'pay', ['by' => 'system']);
$em->flush(); // Le @Version protège contre les mises à jour concurrentes
});
}
Pour des workflows parallèles (type workflow), utilisez multiple_state avec une colonne JSON:
marking_store:
type: multiple_state
property: places
Et côté entité:
#[ORM\Column(type: 'json')]
private array $places = [];
Visualiser et documenter le flux
Générez un graphe Graphviz depuis la configuration:
bin/console workflow:dump order --format=dot | dot -Tsvg -o var/order.svg
- Partagez le SVG aux équipes produit pour valider le flux.
- Utilisez metadata (label, color) sur places/transitions pour améliorer la lisibilité.
- Maintenez la doc à jour: le graphe est dérivé de la vérité de référence (la config).
Bonnes pratiques et pièges courants
- Distinguez modèle et orchestration:
- Les transitions décrivent l’état; les effets de bord vivent dans les subscribers.
- Petits graphes, grandes règles:
- Évitez les “usines à gaz” de transitions. Gardez des états macro et encodez la finesse dans la donnée ou les guards.
- Idempotence:
- Vos listeners d’événements qui appellent des systèmes externes doivent être idempotents (références uniques, déduplication).
- Concurrence:
- Activez le verrouillage optimiste (@Version) et travaillez en transactions.
- Testabilité:
- Testez can/apply des transitions dans des tests unitaires rapides. Ajoutez des tests d’intégration pour les subscribers.
- Observabilité:
- Loggez les transitions (nom, sujet, contexte) et exposez des métriques (compteur de transitions, erreurs de guard).
Cas pratiques
1) Validation éditoriale (workflow parallèle)
Un article doit être relu en FR et EN avant publication.
framework:
workflows:
article_review:
type: workflow
initial_marking: [fr_review, en_review]
supports: [App\Entity\Article]
marking_store:
type: multiple_state
property: places
places: [fr_review, fr_ready, en_review, en_ready, published]
transitions:
approve_fr: { from: fr_review, to: fr_ready }
approve_en: { from: en_review, to: en_ready }
publish:
from: [fr_ready, en_ready]
to: published
guard: "is_granted('ARTICLE_PUBLISH', subject)"
- Deux pistes avancent en parallèle.
- La publication n’est possible que lorsque les deux validations sont terminées.
2) Commandes: paiement, expédition, annulation
- state_machine simple avec guards métier (seuils, compte vérifié).
- Event entered.paid pour capturer le paiement de façon asynchrone.
- Event transition.cancel pour notifier ERP et rembourser si nécessaire.
3) Intégrations externes robustes
- Combinez Workflow (états) et un bus de messages pour les effets de bord (saga légère).
- Stockez des corrélations (références externes) pour rejouer en cas d’échec sans dupliquer les actions.
- Utilisez les événements announce pour pré-notifier une UI (ex: bouton expédier s’affiche quand la transition est possible).
Aller plus loin (sans répéter)
- Associez Messenger pour la résilience des intégrations, la gestion des retries et l’idempotence (voir notre article dédié).
- Pour le temps réel (progression des étapes), exposez les transitions via SSE/Mercure/WebSockets sans transformer votre front en SPA.
- Reliez la sécurité fine (Voters/attributs) à vos guards pour une autorisation cohérente sur toute la stack.
Conclusion
Symfony Workflow apporte une ossature claire à vos processus: lisible, testable et visualisable. En combinant state machine/workflow, guards expressifs, événements bien placés, persistance maîtrisée et graphes partagés, vous réduisez la complexité accidentelle et accélérez la livraison.
Prêt à cartographier et fiabiliser vos flux métier ? Contactez-nous pour un audit de vos processus ou découvrez nos ressources avancées sur Symfony Workflow.