Symfony 8.2 permet de déclarer un workflow ou une state machine avec les attributs PHP #[AsWorkflow] et #[Transition], directement sur une classe de service, sans YAML. Le composant Workflow modélise le cycle de vie d'un objet comme une suite de places reliées par des transitions. Présentée le 14 octobre 2026 sur le blog officiel, contribution de Grégoire Pineau (PR #61935), la fonctionnalité arrive avec Symfony 8.2, attendu en novembre 2026.
Ouvre le config/packages/workflow.yaml d'un projet qui a deux ans. Les transitions sont à un endroit, les listeners dans une autre classe, branchés sur des noms d'événements en chaînes de caractères, et les statuts dans une enum ou des constantes en vrac. Quand tu renommes une transition, rien ne casse à la compilation : tu le découvres en production, sur un apply() qui lève une exception. Le composant est solide ; ce qui a toujours coincé, c'est la façon de le décrire.
Pourquoi définir un workflow en attributs plutôt qu’en YAML ?
Avec les attributs, les transitions deviennent des constantes de classe : ton IDE les suit, PHPStan les vérifie, et tu les renommes en un clic. Les listeners vivent dans la même classe que la définition. Le design de la PR est volontairement conservateur : les attributs sont convertis vers le format de la configuration classique, puis validés et enregistrés par le même code que le YAML. La sémantique ne change donc pas. Après les formulaires définis par attributs, c'est au tour des workflows de quitter la configuration déclarative.
#[AsWorkflow] et #[Transition] : un premier workflow en vingt lignes
// src/Workflow/TaskWorkflow.php
namespace App\Workflow;
use App\Entity\Task;
use Symfony\Component\Workflow\Attribute\AsWorkflow;
use Symfony\Component\Workflow\Attribute\Transition;
use Symfony\Component\Workflow\WorkflowTrait;
#[AsWorkflow(supports: Task::class)]
final class TaskWorkflow
{
use WorkflowTrait;
#[Transition(from: 'new', to: 'processing')]
public const START = 'start';
#[Transition(from: 'processing', to: 'done')]
public const FINISH = 'finish';
}Ce que Symfony infère de cette classe, qui doit être un service autoconfiguré :
- Le nom du workflow est le nom court de la classe en snake_case, sans le suffixe
Workflow: icitask. L’argumentnamepermet de le forcer. - Le type par défaut est une state machine ; pour un vrai workflow multi-places, passe
type: WorkflowType::Workflow. - Les places sont déduites des transitions, et la première place rencontrée devient la place initiale.
- Le marking est stocké par défaut dans la propriété
markingdu sujet. - La valeur de chaque constante est le nom de la transition.
Injecter le workflow et utiliser les constantes
Le WorkflowTrait est optionnel, mais il vaut le coup : il expose can(), apply() et les autres méthodes du workflow directement sur ta classe. Pour appliquer une transition, injecte la classe et appelle can() puis apply() avec la constante :
public function __construct(
private TaskWorkflow $taskWorkflow,
) {
}
public function start(Task $task): Response
{
if ($this->taskWorkflow->can($task, TaskWorkflow::START)) {
$this->taskWorkflow->apply($task, TaskWorkflow::START);
}
// ...
}Selon la PR, ta classe ne devient pas elle-même le service de workflow : un compiler pass crée à côté les services habituels (state_machine.task, l'alias d'autowiring, l'entrée du registry, la commande workflow:dump et le panneau du profiler), et ta classe leur délègue. Les injections existantes par WorkflowInterface continuent donc de fonctionner.
Enums PHP comme places : #[Place], markingProperty et places:
Une définition en chaînes fonctionne, mais tu as presque toujours une enum de statuts à côté. Les arguments from, to et initialMarking acceptent les cases d'une enum string-backed, et l'attribut #[Place] pose les métadonnées sur les cases elles-mêmes. Exemple du billet :
// src/Config/TaskStatus.php
namespace App\Config;
use Symfony\Component\Workflow\Attribute\Place;
enum TaskStatus: string
{
#[Place(metadata: ['label' => 'New'])]
case New = 'new';
#[Place(metadata: ['label' => 'Processing', 'bg_color' => '#eee'])]
case Processing = 'processing';
case Done = 'done';
case Failed = 'failed';
}
// src/Workflow/TaskWorkflow.php
namespace App\Workflow;
use App\Config\TaskStatus;
#[AsWorkflow(supports: Task::class, markingProperty: 'status')]
final class TaskWorkflow
{
use WorkflowTrait;
#[Transition(from: TaskStatus::New, to: TaskStatus::Processing)]
public const START = 'start';
#[Transition(from: TaskStatus::Processing, to: TaskStatus::Failed)]
public const FAIL = 'fail';
// repeat the attribute to define several transitions with the same name
#[Transition(from: TaskStatus::Processing, to: TaskStatus::Done)]
#[Transition(from: TaskStatus::Failed, to: TaskStatus::Done)]
public const FINISH = 'finish';
}Migration Symfony en vue ?
Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.
Ajouter places: TaskStatus::class à #[AsWorkflow] transforme chaque case en place, et Symfony lève une erreur si le workflow en utilise une qui n'appartient pas à l'enum : une faute de frappe devient une erreur de configuration signalée au build. Si tes enums string-backed te sont moins familières, notre article sur les enums et readonly en production fait le point.
Un complément absent du billet : la PR de suivi #66722, mergée le 9 octobre 2026 dans la branche 8.2, ajoute #[Place(initial: true)]. Tu peux ainsi marquer explicitement l'état initial au lieu de dépendre de l'ordre des cases ou des transitions. Un initialMarking explicite sur #[AsWorkflow] garde la priorité.
Guards et listeners dans la classe : #[AsGuardListener]
La classe est un service ordinaire : elle injecte ses dépendances, expose ses propres méthodes et porte ses listeners. Dans cette classe, l'argument workflow des attributs comme #[AsGuardListener] devient optionnel, puisque le listener écoute forcément le workflow qu'elle définit :
use Psr\Log\LoggerInterface;
use Symfony\Component\Workflow\Attribute\AsGuardListener;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Marking;
// ...
#[AsWorkflow(supports: Task::class, markingProperty: 'status')]
final class TaskWorkflow
{
use WorkflowTrait;
#[Transition(from: TaskStatus::New, to: TaskStatus::Processing)]
public const START = 'start';
public function __construct(
private LoggerInterface $logger,
) {
}
public function start(Task $task, User $user): Marking
{
$this->logger->info('Starting task {id}', ['id' => $task->getId()]);
return $this->apply($task, self::START, ['user' => $user]);
}
#[AsGuardListener(transition: self::START)]
public function guardStart(GuardEvent $event): void
{
if ($event->getSubject()->isLocked()) {
$event->setBlocked(true, 'The task is locked.');
}
}
}Les règles de transition, le garde et le point d'entrée métier se lisent dans le même fichier. Pour reprendre un module que tu ne connais pas, c'est un vrai gain. Si plusieurs listeners se branchent sur les mêmes événements, l'ordre relatif des listeners de Symfony 8.2 permet de les ordonner sans priorité numérique.
Ce que les attributs ne changent pas
Entre #[AsWorkflow] et #[Transition], on retrouve les options de la configuration classique : expressions de garde, métadonnées, arcs pondérés, marking stores personnalisés, audit trail, événements à dispatcher. Quelques contraintes, relevées dans la PR :
- Une state machine accepte une seule place d’entrée et une seule de sortie par
#[Transition]: pour plusieurs entrées, répète l’attribut. - Sans
supportsnisupportStrategy, le workflow n’est pas enregistré dans le registry : il reste injectable, mais les fonctions Twig commeworkflow_can()ne le voient pas. - Un nom en doublon avec un workflow YAML existant fait échouer la compilation, avec le nom de la classe fautive.
- Si ton entité stocke son statut dans
status, déclaremarkingProperty: 'status': le défautmarkingserait introuvable.
Rien n'indique que la configuration YAML soit dépréciée : les deux approches coexistent, et tu peux migrer un workflow à la fois. La PR affirme que workflow:dump fonctionne avec ces définitions puisqu'il passe par les mêmes services ; je ne l'ai pas vérifié sur un projet réel, teste-le avant de supprimer un YAML dont dépend ta documentation.
Faut-il migrer tes workflows existants ?
Sur un workflow nouveau ou un module que tu retouches de toute façon, pars directement sur les attributs, avec une enum et places:. La validation au build et le renommage fiable valent le changement d'habitude. Pour un graphe de quinze transitions relu par des non-développeurs, le YAML et un workflow:dump restent plus lisibles. Et ne lance pas de migration en masse avant la version stable. Pour t'y préparer :
- Recense tes workflows et repère ceux qui ont déjà une enum de statuts : ce sont les premiers candidats.
- Migre-en un seul, en gardant le même nom pour conserver les identifiants de services.
- Remplace les chaînes de transitions par les constantes, et marque l’état initial avec
#[Place(initial: true)]. - Ajoute un test qui rejoue les transitions (
can()etapply()) avant et après la migration.
Réécrire un workflow en attributs est un petit chantier, mais il se répète sur chaque entité à états de ton application, et les erreurs apparaissent sur les données déjà en base. Sur les migrations Symfony que pilote Bear Upgrade, ce type de refonte passe en Merge Requests découpées, chacune accompagnée de tests qui rejouent les transitions avant et après.

Écrit par
Flavien Métivier
Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.
Mon parcours