Grégoire Pineau
Contributed by Grégoire Pineau in #61935

The Workflow component lets you model the life cycle of your objects as workflows or state machines. Until now, you defined them in YAML or PHP configuration files. Symfony 8.2 lets you define them with PHP attributes, alongside their transitions and event listeners.

Defining Workflows with Attributes

Add the #[AsWorkflow] attribute to any autoconfigured service class of your application and define each transition with a #[Transition] attribute on a constant whose value is the transition name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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';
}

This defines a state machine called task (the class name without the Workflow suffix; pass type: WorkflowType::Workflow for a workflow). Places are inferred from the transitions, the first one (new) is the initial place, and the marking is stored in the marking property of the subject.

The optional WorkflowTrait exposes the workflow methods on your class, so you can inject it and use its constants instead of strings:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
    }

    // ...
}

Using Enums as Places

The from, to and initialMarking arguments also accept cases of string-backed enums. Use the #[Place] attribute on those cases to add metadata to the places:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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';
}

If you add places: TaskStatus::class to #[AsWorkflow], every enum case becomes a place, and Symfony throws an error if the workflow uses a place that isn't one of its cases.

Adding Your Own Logic

The workflow class is a regular service, so it can inject dependencies, define its own methods and contain its event listeners. Inside this class, the workflow argument of listener attributes like #[AsGuardListener] is optional, because they listen to the workflow defined by the class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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.');
        }
    }
}

Between #[AsWorkflow] and #[Transition], you get all the options of the workflow configuration (guard expressions, metadata, weighted arcs, custom marking stores, audit trail, etc.). Check out the Workflow component docs for the full reference.

Published in #Living on the edge