Workflow vs StateMachine — le choix fondamental
Workflow et StateMachine partagent la même API — la différence est que StateMachine limite à un seul état à la fois.
Quand tu modélises un cycle de vie — une commande qui passe de draft à confirmée à expédiée, ou un document avec plusieurs états parallèles — tu as deux outils dans le composant Workflow de Symfony : Workflow et StateMachine. Même API, comportement fondamentalement différent.
Un objet peut se trouver dans plusieurs places simultanément. Le Marking stocke un tableau :
['reviewed' => 1, 'featured' => 1].
Idéal pour les processus parallèles (ex. : un article en cours de relecture ET de validation légale).
Un objet ne peut se trouver que dans une seule place à la fois. Le Marking stocke une string :
'published'.
Idéal pour les cycles de vie séquentiels simples (ex. : commande → paiement → expédition).
Le Marking
Le Marking est l'objet qui représente l'état courant. Sa structure reflète le type :
// Workflow — plusieurs places possibles
$marking->getPlaces(); // ['draft' => 1, 'featured' => 1]
$marking->has('draft'); // true
$marking->has('published'); // false
// StateMachine — une seule place
$marking->getPlaces(); // ['published' => 1]
MethodMarkingStore — le pont vers l'objet
MethodMarkingStore lit et écrit l'état sur l'entité en appelant ses accesseurs :
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
// Pour un Workflow (places multiples) :
new MethodMarkingStore(singleState: false, property: 'marking')
// → appelle $entity->getMarking() / setMarking(array $places)
// → la propriété doit stocker un tableau JSON
// Pour une StateMachine (place unique) :
new MethodMarkingStore(singleState: true, property: 'state')
// → appelle $entity->getState() / setState(string $state)
// → la propriété stocke une string (ou un Backed Enum)
Quand choisir l'un ou l'autre
| Critère | Workflow | StateMachine |
|---|---|---|
| États simultanés ? | Oui (plusieurs places) | Non (une seule place) |
| Modèle formel | Réseau de Petri | Automate fini déterministe |
| Stockage en DB | JSON ou tableau sérialisé | VARCHAR / ENUM string |
| Cas d'usage typique | Documents, processus parallèles | Commandes, tickets, statuts |
| Complexité | Plus expressif, plus complexe à déboguer | Simple, lisible, debuggable |
Explore la différence ci-dessous :
Configuration YAML — définir le graphe
La configuration YAML définit l'intégralité du graphe — places, transitions, guards, metadata, marking_store.
Toute la configuration d'un workflow vit dans config/packages/workflow.yaml. Ce fichier
définit le graphe complet : places, transitions, marking store, events à dispatcher, et métadonnées.
Symfony génère un service nommé workflow.<nom> (ou state_machine.<nom>).
Exemple complet — BlogPost
# config/packages/workflow.yaml
framework:
workflows:
blog_publishing:
type: 'workflow' # ou 'state_machine'
marking_store:
type: 'method'
property: 'marking' # $blogPost->getMarking() / setMarking()
supports:
- App\Entity\BlogPost
initial_marking: 'draft'
places:
draft:
metadata: { color: '#ff6b6b' }
review:
metadata: { color: '#f6ad55' }
published:
metadata: { color: '#68d391' }
rejected:
metadata: { color: '#fc6d6d' }
transitions:
to_review:
from: 'draft'
to: 'review'
metadata: { label: 'Soumettre à relecture' }
publish:
from: 'review'
to: 'published'
guard: "is_granted('ROLE_EDITOR')"
reject:
from: 'review'
to: 'rejected'
redraft:
from: ['rejected', 'review']
to: 'draft'
events_to_dispatch: ~ # null = tous ; [] = aucun sauf GUARD
audit_trail:
enabled: true
Injection du service généré
Symfony crée automatiquement le service workflow.blog_publishing. Deux façons de l'injecter :
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Workflow\WorkflowInterface;
// Option 1 : #[Target] — recommandé, explicite
public function __construct(
#[Target('blog_publishing')]
private readonly WorkflowInterface $workflow,
) {}
// Option 2 : par nom de paramètre (autowiring nommé)
public function __construct(
private readonly WorkflowInterface $blogPublishingWorkflow,
) {}
Pour un type
workflow : workflow.blog_publishingPour un type
state_machine : state_machine.blog_publishingDans les deux cas, le service implémente
WorkflowInterface.
#[Target('blog_publishing')] résout automatiquement le bon service.
events_to_dispatch — contrôler les événements
Par défaut (null), tous les événements sont dispatchés. Pour optimiser les performances :
events_to_dispatch: # Lister uniquement les événements dont tu as besoin
- workflow.guard
- workflow.transition
- workflow.completed
events_to_dispatch
aux seuls événements écoutés réduit significativement l'overhead.
workflow.guard est toujours dispatché, même avec [].
WorkflowInterface — can, apply, getEnabledTransitions
L'API de WorkflowInterface se résume à trois opérations : vérifier, appliquer, explorer.
WorkflowInterface est l'unique point d'entrée pour interagir avec un workflow depuis le code
applicatif. Elle expose trois opérations : vérifier (can), appliquer
(apply), et explorer (getEnabledTransitions).
L'interface complète
interface WorkflowInterface
{
public function getMarking(object $subject): Marking;
// Vérifier si une transition est possible (sans appliquer)
public function can(object $subject, string $transitionName): bool;
// Appliquer une transition — retourne le nouveau Marking
public function apply(object $subject, string $transitionName, array $context = []): Marking;
// Toutes les transitions accessibles depuis l'état courant
public function getEnabledTransitions(object $subject): array;
// Une transition spécifique accessible (ou null)
public function getEnabledTransition(object $subject, string $name): ?Transition;
// Liste détaillée des blocages pour une transition
public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList;
public function getName(): string;
public function getDefinition(): Definition;
public function getMarkingStore(): MarkingStoreInterface;
public function getMetadataStore(): MetadataStoreInterface;
}
Utilisation typique dans un service
final class BlogPostPublisher
{
public function __construct(
#[Target('blog_publishing')]
private readonly WorkflowInterface $workflow,
) {}
public function submitForReview(BlogPost $post): void
{
// Vérification avant application
if (!$this->workflow->can($post, 'to_review')) {
throw new \LogicException('Cannot submit for review from current state.');
}
// Application avec contexte optionnel
$this->workflow->apply($post, 'to_review', [
'user' => $this->security->getUser(),
'reason' => 'Ready for editorial review',
]);
}
/** @return Transition[] */
public function getAvailableActions(BlogPost $post): array
{
return $this->workflow->getEnabledTransitions($post);
}
}
NotEnabledTransitionException et UndefinedTransitionException
apply() peut lancer deux exceptions distinctes :
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\Exception\UndefinedTransitionException;
try {
$this->workflow->apply($post, 'publish');
} catch (NotEnabledTransitionException $e) {
// La transition existe mais est bloquée (guard, mauvais état)
$blockers = $e->getTransitionBlockerList();
foreach ($blockers as $blocker) {
$this->logger->warning($blocker->getMessage());
}
} catch (UndefinedTransitionException $e) {
// La transition n'existe pas dans ce workflow
}
Le tableau
$context est propagé à tous les événements via
TransitionEvent::getContext(). C'est le moyen de passer des métadonnées aux
listeners sans modifier l'entité (utilisateur courant, raison, timestamp, etc.).
can() avant apply() quand la transition peut
échouer légitimement (interface utilisateur). Appelle apply() directement quand
l'état est garanti par le contexte applicatif — et gère l'exception sinon.
Guards — bloquer une transition
Un guard évalue si une transition est autorisée — GuardEvent::setBlocked(true) empêche apply().
Un guard est le gardien d'une transition. Il s'exécute avant toute application et peut bloquer
la transition en appelant GuardEvent::setBlocked(true). Si bloqué, apply()
lance une NotEnabledTransitionException.
GuardEvent
use Symfony\Component\Workflow\Event\GuardEvent;
final class BlogPostWorkflowGuard
{
public function onPublish(GuardEvent $event): void
{
/** @var BlogPost $post */
$post = $event->getSubject();
if (!$post->hasReviewer()) {
$event->setBlocked(true, 'A reviewer must be assigned before publishing.');
return;
}
if (!$this->security->isGranted('ROLE_EDITOR')) {
$event->setBlocked(true, 'Only editors can publish.');
}
}
}
Enregistrement du listener
Le nommage des événements de guard suit une convention à trois niveaux de précision :
# Niveau 1 : tous les guards de tous les workflows
'workflow.guard'
# Niveau 2 : tous les guards du workflow blog_publishing
'workflow.blog_publishing.guard'
# Niveau 3 : guard de la transition 'publish' dans blog_publishing
'workflow.blog_publishing.guard.publish'
# config/services.yaml
App\Workflow\BlogPostWorkflowGuard:
tags:
- name: kernel.event_listener
event: 'workflow.blog_publishing.guard.publish'
method: 'onPublish'
#[AsGuard] — l'attribut Symfony 6.4+
Depuis Symfony 6.4, #[AsGuard] remplace la déclaration de tag manuelle :
use Symfony\Component\Workflow\Attribute\AsGuard;
final class PublishGuard
{
#[AsGuard(transition: 'publish', workflow: 'blog_publishing')]
public function __invoke(GuardEvent $event): void
{
$post = $event->getSubject();
if (!$post->hasReviewer()) {
$event->setBlocked(true, 'Reviewer required.');
}
}
}
Guard en YAML — expression language
La clé guard: dans la configuration supporte les expressions Symfony :
transitions:
publish:
from: 'review'
to: 'published'
guard: "is_granted('ROLE_EDITOR') and subject.hasReviewer()"
is_granted()). Pour une logique complexe (injections de
services, conditions multiples, messages d'erreur personnalisés), utilise un listener ou
#[AsGuard].
buildTransitionBlockerList() retourne un objet itérable de TransitionBlocker.
Chaque blocker a un getMessage() et un getCode() (utiles pour les API REST
qui doivent expliquer pourquoi une action est impossible).
Les 7 événements du workflow
Sept événements jalonnent chaque transition — de GUARD (avant) à ANNOUNCE (après) — chacun avec un rôle précis.
Sept événements balisent chaque application de transition. Comprendre leur ordre et leur rôle permet de choisir le bon point d'interception pour chaque besoin.
Les 7 événements dans l'ordre d'exécution
| # | Constante WorkflowEvents | Nom de l'événement | Classe | Rôle |
|---|---|---|---|---|
| 1 | GUARD |
workflow.guard |
GuardEvent |
Bloquer ou autoriser — avant tout |
| 2 | LEAVE |
workflow.leave |
LeaveEvent |
La place courante est quittée |
| 3 | TRANSITION |
workflow.transition |
TransitionEvent |
La transition s'applique |
| 4 | ENTER |
workflow.enter |
EnterEvent |
La nouvelle place est entrée (Marking pas encore mis à jour) |
| 5 | ENTERED |
workflow.entered |
EnteredEvent |
La nouvelle place est active (Marking mis à jour) |
| 6 | COMPLETED |
workflow.completed |
CompletedEvent |
Transition terminée avec succès |
| 7 | ANNOUNCE |
workflow.announce |
AnnounceEvent |
Annonce des transitions suivantes possibles |
Convention de nommage des listeners
Chaque événement peut être écouté à trois niveaux de granularité. Exemple pour ENTERED :
// Tous les workflows, toutes les places
'workflow.entered'
// Seulement le workflow blog_publishing
'workflow.blog_publishing.entered'
// Seulement l'entrée dans la place 'published' de blog_publishing
'workflow.blog_publishing.entered.published'
Cas d'usage recommandés
| Événement | Cas d'usage typique |
|---|---|
GUARD |
Contrôle d'accès, validation métier avant transition |
LEAVE |
Nettoyage de l'état courant, logging de sortie |
TRANSITION |
Audit trail, enrichissement du contexte |
ENTER |
Initialisation de la nouvelle place (avant persistence) |
ENTERED |
Envoi de notifications, mise à jour de champs dérivés (après persistence) |
COMPLETED |
Déclenchement d'actions post-transition (emails, jobs) |
ANNOUNCE |
Pré-évaluation des guards pour les transitions suivantes (UI) |
Tous les événements incluent
HasContextTrait, qui expose getContext(): array.
Le contexte passé à apply($subject, $name, $context) est disponible dans chaque
listener — c'est le canal de communication entre l'appelant et les listeners.
Exemple : audit trail sur COMPLETED
use Symfony\Component\Workflow\Event\CompletedEvent;
final class WorkflowAuditListener
{
public function onCompleted(CompletedEvent $event): void
{
$this->auditLog->record(
subject: $event->getSubject(),
transition: $event->getTransition()->getName(),
context: $event->getContext(),
);
}
}
$event->getMarking()).
MarkingStore et persistance
Le MarkingStore est le pont entre le Workflow et la persistance — MethodMarkingStore délègue aux accesseurs de l'objet.
Le MarkingStore est le composant qui lit et écrit l'état du workflow sur l'objet métier.
MethodMarkingStore est l'implémentation fournie par Symfony — elle délègue à des
accesseurs (get/set) de l'objet.
MethodMarkingStore en détail
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
// Constructeur
new MethodMarkingStore(
singleState: false, // true = StateMachine, false = Workflow
property: 'marking' // nom de la propriété sur l'entité
);
Avec singleState: false (Workflow), le store appelle :
$subject->getMarking()→ doit retournerarray<string, int>$subject->setMarking(array $marking, Marking $markingObject)
Avec singleState: true (StateMachine), le store appelle :
$subject->getState()→ doit retournerstring(ou Backed Enum)$subject->setState(string $state)
Implémentation Doctrine recommandée
Pour une StateMachine (state = string)
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Order
{
#[ORM\Column(type: 'string', length: 50)]
private string $state = 'new';
public function getState(): string { return $this->state; }
public function setState(string $state): void { $this->state = $state; }
}
Pour un Workflow (marking = tableau JSON)
#[ORM\Entity]
class BlogPost
{
#[ORM\Column(type: 'json', nullable: true)]
private ?array $marking = null;
public function getMarking(): ?array { return $this->marking; }
public function setMarking(?array $marking): void { $this->marking = $marking; }
}
Custom MarkingStore
Si tu as des besoins particuliers (stockage en Redis, log d'historique, champ calculé),
implémente MarkingStoreInterface :
use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface;
use Symfony\Component\Workflow\Marking;
final class AuditedMarkingStore implements MarkingStoreInterface
{
public function getMarking(object $subject): Marking
{
return new Marking($subject->getState() ? [$subject->getState() => 1] : []);
}
public function setMarking(object $subject, Marking $marking, array $context = []): void
{
$places = array_keys($marking->getPlaces());
$subject->setState($places[0] ?? null);
$this->audit->log($subject, $context);
}
}
marking_store: { service: 'App\Workflow\AuditedMarkingStore' }.
Metadata — enrichir le graphe
Les métadonnées enrichissent le graphe avec des informations métier — couleurs, labels, descriptions — sans modifier le comportement.
Les métadonnées du workflow sont des données attachées au graphe lui-même — pas à l'objet métier. Elles servent à enrichir l'interface utilisateur (couleurs, labels) et à stocker des configurations sans polluer l'entité.
Déclaration en YAML
framework:
workflows:
blog_publishing:
metadata:
title: 'Publication des articles'
places:
draft:
metadata: { label: 'Brouillon', color: '#ff6b6b' }
published:
metadata: { label: 'Publié', color: '#68d391' }
transitions:
to_review:
from: 'draft'
to: 'review'
metadata: { label: 'Soumettre', icon: '✉️' }
MetadataStoreInterface
use Symfony\Component\Workflow\WorkflowInterface;
$metaStore = $workflow->getMetadataStore();
// Metadata du workflow entier
$title = $metaStore->getWorkflowMetadata()['title'] ?? null;
// Metadata d'une place
$color = $metaStore->getPlaceMetadata('draft')['color'] ?? null;
// Metadata d'une transition (objet Transition requis)
$transition = $workflow->getEnabledTransition($subject, 'to_review');
if ($transition) {
$label = $metaStore->getTransitionMetadata($transition)['label'] ?? null;
}
// Raccourci avec getMetadata(key, subject)
// subject = null → workflow, string → place, Transition → transition
$color = $metaStore->getMetadata('color', 'draft');
Utilisation en Twig
Symfony expose une extension Twig pour accéder aux métadonnées directement dans les templates :
{# Accès aux métadonnées d'une place #}
{% set meta = workflow_metadata(post, 'color', place) %}
<span style="background: {{ meta }}">{{ place }}</span>
{# Transitions accessibles avec leurs métadonnées #}
{% for transition in workflow_transitions(post, 'blog_publishing') %}
{% set label = workflow_metadata(post, 'label', transition) %}
<button>{{ label ?? transition.name }}</button>
{% endfor %}
Registry — multi-workflow
Le Registry permet d'accéder à n'importe quel workflow pour un objet donné — utile quand un objet participe à plusieurs workflows.
Le Registry est le service central qui référence tous les workflows de l'application.
Il permet d'obtenir le bon workflow pour un objet donné, sans injecter chaque workflow individuellement.
Injection du Registry
use Symfony\Component\Workflow\Registry;
final class ContentManager
{
public function __construct(
private readonly Registry $workflows,
) {}
}
API du Registry
// Obtenir un workflow spécifique pour un objet
$workflow = $this->workflows->get($post, 'blog_publishing');
// Obtenir le premier workflow qui supporte l'objet (si un seul workflow)
$workflow = $this->workflows->get($post);
// Vérifier si un workflow supporte l'objet
if ($this->workflows->has($post, 'moderation')) {
// ...
}
// Obtenir tous les workflows supportant l'objet
$allWorkflows = $this->workflows->all($post);
foreach ($allWorkflows as $workflow) {
echo $workflow->getName();
}
Cas d'usage : objet avec plusieurs workflows
Un même objet peut participer à plusieurs workflows indépendants :
# config/packages/workflow.yaml
framework:
workflows:
blog_publishing:
supports: [App\Entity\BlogPost]
# ...
blog_moderation:
supports: [App\Entity\BlogPost]
# ...
final class BlogPostService
{
public function moderateAndPublish(BlogPost $post): void
{
$publishing = $this->workflows->get($post, 'blog_publishing');
$moderation = $this->workflows->get($post, 'blog_moderation');
if ($moderation->can($post, 'approve')) {
$moderation->apply($post, 'approve');
}
if ($publishing->can($post, 'publish')) {
$publishing->apply($post, 'publish');
}
}
}
Préfère l'injection directe via
#[Target] dans la grande majorité des cas : plus
explicite, plus testable, meilleure lisibilité. Utilise le Registry uniquement quand tu dois
gérer dynamiquement plusieurs workflows pour un objet (dispatchers génériques, admin tools,
interfaces de modération polyvalentes).
get($subject) sans préciser le nom, Symfony lève une
InvalidArgumentException. Toujours spécifier le nom quand plusieurs workflows
coexistent pour le même type d'objet.
Visualisation et debug
workflow:dump génère une représentation graphique du workflow — indispensable pour la documentation et le debug.
Le composant Workflow propose des outils de visualisation et de debug qui transforment la configuration YAML en diagramme et donnent de la visibilité sur l'état courant en Twig.
workflow:dump
La commande workflow:dump génère une représentation du graphe dans trois formats :
# Format DOT (Graphviz) — défaut
php bin/console workflow:dump blog_publishing | dot -Tpng -o workflow.png
# Format PlantUML
php bin/console workflow:dump blog_publishing --dump-format=puml
# Format Mermaid
php bin/console workflow:dump blog_publishing --dump-format=mermaid
# Avec les métadonnées et les listeners
php bin/console workflow:dump blog_publishing --with-metadata --with-listeners
Extension Twig — les 5 fonctions
| Fonction | Retour | Usage |
|---|---|---|
workflow_can(subject, name, workflow?) |
bool |
Tester si une transition est possible |
workflow_transitions(subject, workflow?) |
Transition[] |
Toutes les transitions accessibles |
workflow_marked_places(subject, workflow?) |
string[] |
Places actives (Marking courant) |
workflow_has_marked_place(subject, place, workflow?) |
bool |
Tester si une place est active |
workflow_metadata(subject, key, placeOrTransition) |
mixed |
Lire les métadonnées du graphe |
Afficher l'état courant dans un template
{# Afficher les places actives #}
{% for place in workflow_marked_places(post) %}
<span class="badge b-ok">{{ place }}</span>
{% endfor %}
{# Boutons d'action — une transition par bouton accessible #}
<div>
{% for transition in workflow_transitions(post, 'blog_publishing') %}
<form method="post" action="/blog/{{ post.id }}/transition">
<input type="hidden" name="transition" value="{{ transition.name }}">
<button type="submit">
{{ workflow_metadata(post, 'label', transition) ?? transition.name }}
</button>
</form>
{% else %}
<p>Aucune transition disponible.</p>
{% endfor %}
</div>
Explore le workflow BlogPost ci-dessous :
Synthèse — guide de choix
Workflow vs StateMachine, les 7 événements, les patterns de production.
Le composant Workflow de Symfony est puissant mais nécessite des choix clairs. Ce guide de synthèse récapitule les décisions clés et les patterns de production.
Guide de choix : Workflow vs StateMachine
| Question | Workflow | StateMachine |
|---|---|---|
| Plusieurs places simultanées ? | Oui — c'est le cas | Non — une seule place |
| Stockage DB | JSON array | VARCHAR / Backed Enum |
| Complexité de debug | Plus complexe | Simple, lisible |
| Cas d'usage typique | Documents multi-validation | Commandes, tickets, réservations |
| Recommandation par défaut | — | Oui — commence ici |
Les 7 événements — récapitulatif
| # | Événement | Cas d'usage |
|---|---|---|
| 1 | GUARD | Bloquer une transition (accès, validation) |
| 2 | LEAVE | Nettoyage de l'état précédent |
| 3 | TRANSITION | Audit trail pendant la transition |
| 4 | ENTER | Init de la nouvelle place (avant persist) |
| 5 | ENTERED | Notifications (après persist) |
| 6 | COMPLETED | Jobs asynchrones post-transition |
| 7 | ANNOUNCE | Pré-évaluation des guards (UI) |
Anti-patterns courants
Les guards YAML avec
is_granted() sont pratiques mais limités. Dès que la condition
implique un service ou plusieurs critères, passe à #[AsGuard] ou un listener.
Un cycle de vie linéaire avec 10 étapes séquentielles est toujours une StateMachine. Ne choisis Workflow que si tu as vraiment besoin de places simultanées.
Si l'entité est supportée par plusieurs workflows,
get($subject) sans nom lance
une exception. Toujours être explicite sur le nom du workflow.
Appelle toujours
can() avant apply() dans les contrôleurs et
services exposés à l'utilisateur. Les exceptions de workflow sont des erreurs de programmation,
pas des erreurs utilisateur.
Checklist de déploiement
- La colonne Doctrine est
VARCHAR(StateMachine) ouJSON(Workflow) - La migration Doctrine est créée et testée
initial_markingest cohérent avec la valeur par défaut de la colonne en DB- Les guards sont testés unitairement (instancier
GuardEventavec un mock de subject) workflow:dumpgénère le diagramme attenduevents_to_dispatchest restreint aux seuls événements écoutés (performance)