$30 off During Our Annual Pro Sale. View Details »

Designing State Machines with the Symfony Workflow Component

Hugo Hamon
February 24, 2023

Designing State Machines with the Symfony Workflow Component

Developing domain specific web applications often rhymes with domain entities having a inner state that moves from one to another when some event or action occurs. One way to constraint, validate and ensure the consistency of an object state within its lifecycle is to setup a state machine. In this talk, you’ll learn how to use the Symfony Workflow component to build a complete and full featured state machine for your PHP domain objects.

Hugo Hamon

February 24, 2023
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Designing State Machines with the
    Symfony Workflow Component
    Hugo Hamon | Confoo, Montreal - Feb. 24th 2023 | [email protected] - @hhamon
    1

    View Slide

  2. 2

    View Slide

  3. https://speakerdeck.com/hhamon
    3

    View Slide

  4. Introduction
    4

    View Slide

  5. 5

    View Slide

  6. 6

    View Slide

  7. 7

    View Slide

  8. 8

    View Slide

  9. 9

    View Slide

  10. 10
    States
    Steps
    Transitions
    Lifecycles
    Validation Business Logic

    View Slide

  11. 11
    The Symfony
    Workflow Component

    View Slide

  12. 12
    “ The Workflow component
    provides tools for managing a
    workflow or a finite state
    machine. ”
    — https://symfony.com

    View Slide

  13. 13
    “ A state machine is a subset of a
    workflow and its purpose is to hold a state
    of your model. ”
    — https://symfony.com

    View Slide

  14. 14
    “ The Finite State Machine aka FSM can
    change from one state to another in
    response to some inputs; the change from
    one state to another is called a transition. ”
    — https://en.wikipedia.org/wiki/Finite-state_machine

    View Slide

  15. Installation
    & Configuration
    15

    View Slide

  16. $ composer require symfony/workflow
    16

    View Slide

  17. # config/packages/workflow.yaml
    framework:
    workflows:
    invoice:
    type: state_machine
    marking_store:
    type: method
    property: status
    supports: App\Entity\Invoice
    initial_marking: draft
    places: []
    transitions: []
    17

    View Slide

  18. # config/packages/workflow.yaml
    framework:
    workflows:
    invoice:
    ...
    places:
    - draft
    - reviewing
    - due
    - disputed
    - paid
    - canceled
    - archived
    18

    View Slide

  19. # config/packages/workflow.yaml
    framework:
    workflows:
    invoice:
    ...
    transitions:
    - { name: amend, from: draft, to: draft }
    - { name: submit_for_review , from: draft, to: reviewing }
    - { name: issue, from: reviewing, to: due }
    - { name: request_amendments , from: reviewing, to: draft }
    - { name: dispute, from: due, to: disputed }
    - { name: accept_dispute , from: disputed, to: canceled }
    - { name: refuse_dispute , from: disputed, to: due }
    - { name: pay_half, from: due, to: due }
    - { name: pay_full, from: due, to: paid }
    - { name: collect_payment , from: due, to: due }
    - { name: close, from: due, to: paid }
    - { name: archive, from: paid, to: archived }
    19

    View Slide

  20. # config/packages/workflow.yaml
    framework:
    workflows:
    invoice:
    ...
    transitions:
    ...
    - name: cancel
    from: [draft, reviewing, due, paid]
    to: canceled
    20

    View Slide

  21. 21
    $ bin/console workflow:dump invoice | dot -Tpng -o workflow.png

    View Slide

  22. $ symfony console debug:autowiring workflow
    22

    View Slide

  23. Audit Trail
    23

    View Slide

  24. Audit Trail
    24
    # config/packages/workflow.yaml
    framework:
    workflows:
    invoice:
    ...
    audit_trail:
    enabled: true

    View Slide

  25. Usage
    25

    View Slide

  26. interface WorkflowInterface
    {
    public function can(object $subject, string $transition): bool;
    public function apply(object $subject, string $transition, array $context = []): Marking;
    public function getEnabledTransitions(object $subject): array;
    public function buildTransitionBlockerList(
    object $subject,
    string $transition,
    ): TransitionBlockerList;
    public function getMarking(object $subject): Marking;
    public function getName(): string;
    public function getDefinition(): Definition;
    public function getMarkingStore(): MarkingStoreInterface;
    public function getMetadataStore(): MetadataStoreInterface;
    }
    26

    View Slide

  27. 27
    Workflow::apply()

    View Slide

  28. 28
    use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
    use Symfony\Component\Workflow\Exception\TransitionException;
    use Symfony\Component\Workflow\Exception\UndefinedTransitionException;
    use Symfony\Component\Workflow\StateMachine;
    final class PayFullInvoiceDisputeController extends AbstractController
    {
    public function __construct (
    private readonly StateMachine $invoiceStateMachine,
    ) {
    }
    public function __invoke(Invoice $invoice): Response
    {
    try {
    $this->invoiceStateMachine->apply($invoice, 'pay_full');
    } catch (UndefinedTransitionException $e) {
    // ...
    } catch (NotEnabledTransitionException $e) {
    // ...
    } catch (TransitionException $e) {
    // ...
    }
    return $this->redirectToRoute ('app_list_invoices' );
    }
    }

    View Slide

  29. 29
    Workflow::can()

    View Slide

  30. 30
    final class PayHalfInvoiceDisputeController extends AbstractController
    {
    public function __construct(
    private readonly StateMachine $invoiceStateMachine,
    ) {
    }
    public function __invoke(Invoice $invoice): Response
    {
    try {
    $this->invoiceStateMachine
    ->apply($invoice, 'pay_half');
    if ($this->invoiceStateMachine->can($invoice, 'close')) {
    $this->invoiceStateMachine->apply($invoice, 'close');
    }
    } catch (TransitionException $e) {
    // ...
    }
    return $this->redirectToRoute('app_list_invoices'
    );
    }
    }

    View Slide

  31. Twig
    Integration
    31

    View Slide

  32. Twig Workflow Functions
    32
    ★ workflow_can(subject, transitionName, name)
    ★ workflow_has_marked_place(subject, placeName, name)
    ★ workflow_marked_places(subject, placesNameOnly, name)
    ★ workflow_metadata(subject, key, metadataSubject, name)
    ★ workflow_transition(subject, transition, name)
    ★ workflow_transition_blockers(subject, transitionName, name)
    ★ workflow_transitions(subject, name)

    View Slide

  33. 33

    {% if workflow_can(invoice, 'pay_half') %}

    Pay half

    {% endif %}
    {% if workflow_can(invoice, 'pay_full') %}

    Pay full

    {% endif %}
    {% if workflow_can(invoice, 'dispute') %}

    Dispute

    {% endif %}

    View Slide

  34. Extension
    Points
    34

    View Slide

  35. 35
    “ The Workflow component
    dispatches series of internal
    events when places and
    transitions are reached. ”

    View Slide

  36. Event System
    36
    ★ workflow.guard
    ★ workflow.[name].guard
    ★ workflow.[name].guard.[transition]
    ★ workflow.leave
    ★ workflow.[name].leave
    ★ workflow.[name].leave.[transition]
    ★ workflow.transition
    ★ workflow.[name].transition
    ★ workflow.[name].transition.[transition]
    ★ workflow.enter
    ★ workflow.[name].enter
    ★ workflow.[name].entered.[transition]
    ★ workflow.entered
    ★ workflow.[name].entered
    ★ workflow.[name].entered.[transition]
    ★ workflow.completed
    ★ workflow.[name].completed
    ★ workflow.[name].completed.[transition]
    ★ workflow.announce
    ★ workflow.[name].announce
    ★ workflow.[name].announce.[transition]

    View Slide

  37. final class InvoiceLifecycleSubscriber implements EventSubscriberInterface
    {
    public static function getSubscribedEvents
    (): array
    {
    return [
    'workflow.invoice.completed' => 'onInvoiceCompletedTransition',
    ];
    }
    ...
    public function onInvoiceCompletedTransition(CompletedEvent $event): void
    {
    $invoice = $event->getSubject();
    \assert($invoice instanceof Invoice);
    $this->entityManager->persist($invoice);
    $this->entityManager->flush();
    }
    }
    37

    View Slide

  38. final class InvoiceLifecycleSubscriber implements EventSubscriberInterface
    {
    public static function getSubscribedEvents
    (): array
    {
    return [
    'workflow.invoice.completed' => ['onInvoiceCompletedTransition', 1024],
    'workflow.invoice.completed.dispute' => ['onInvoiceCompletedDisputeTransition', 512],
    'workflow.invoice.completed.accept_dispute' => ['onInvoiceCompletedAcceptDisputeTransition', 512],
    ];
    }
    public function __construct(
    ...
    private readonly InvoiceDisputeMailer $disputeMailer
    ,
    ) {
    }
    public function onInvoiceCompletedDisputeTransition
    (CompletedEvent $event): void
    {
    $this->notifyDisputeCreated
    ($this->getInvoice($event)->getDisputes()->last());
    }
    public function onInvoiceCompletedAcceptDisputeTransition
    (CompletedEvent $event): void
    {
    $this->notifyDisputeAccepted
    ($this->getInvoice($event)->getDisputes()->last());
    }
    } 38

    View Slide

  39. Controlling Dispatched Events
    39
    framework:
    workflows:
    invoice:
    ...
    # Only dispatch these events
    events_to_dispatch:
    - workflow.leave
    - workflow.completed
    # Don’t dispatch any events
    events_to_dispatch: []

    View Slide

  40. Guard
    Controls
    40

    View Slide

  41. 41
    “ Guards are designed as event
    listeners. They run some business
    logic aimed at controlling
    whether or not a certain
    transition can be performed. ”

    View Slide

  42. Role Based Security Expression
    42
    framework:
    workflows:
    invoice:
    ...
    transitions:
    - name: submit_for_review
    from: draft
    to: reviewing
    guard: "is_granted('ROLE_JUNIOR_ACCOUNTANT')"
    - name: request_amendments
    from: reviewing
    to: draft
    guard: "is_granted('ROLE_SENIOR_ACCOUNTANT')"
    - name: pay_half
    from: due
    to: due
    guard: "is_granted('ROLE_CUSTOMER')"
    - name: pay_full
    from: due
    to: paid
    guard: "is_granted('ROLE_CUSTOMER')"

    View Slide

  43. Subject Based Expression
    43
    framework:
    workflows:
    invoice:
    ...
    transitions:
    - name: dispute
    from: due
    to: disputed
    guard: "is_granted('ROLE_CUSTOMER') and subject.isDisputable()"

    View Slide

  44. Subject Based Expression
    44
    class Invoice
    {
    ...
    public function isDisputable(): bool
    {
    return $this->getTotalNetAmount()->isPositive()
    && $this->getTotalPaidAmount()->isZero();
    }
    }

    View Slide

  45. Security Voter Based Expression
    45
    framework:
    workflows:
    invoice:
    ...
    transitions:
    - name: dispute
    from: due
    to: disputed
    guard: "is_granted('INVOICE_VIEW', subject)"

    View Slide

  46. Guards - Custom Event Listener
    46
    final class PayInvoiceGuardSubscriber implements EventSubscriberInterface
    {
    public static function getSubscribedEvents (): array
    {
    return [
    'workflow.invoice.guard.pay_half' => 'onGuardPayHalfTransition',
    ];
    }
    public function onGuardPayHalfTransition(GuardEvent $event): void
    {
    $invoice = $event->getSubject();
    \assert( $invoice instanceof Invoice);
    [$firstHalf,] = $invoice->getTotalNetAmount ()->allocateTo(2);
    if ($invoice->getTotalPaidAmount ()->isPositive()
    && !$invoice->getTotalPaidAmount ()->equals($firstHalf)) {
    $event->setBlocked(true, 'Invoice must be paid in full!');
    }
    }
    }

    View Slide

  47. 47
    final class PayHalfInvoiceDisputeController extends AbstractController
    {
    public function __construct(
    private readonly StateMachine $invoiceStateMachine,
    ) {
    }
    public function __invoke(Invoice $invoice): Response
    {
    try {
    $this->invoiceStateMachine->apply($invoice, 'pay_half');
    if ($this->invoiceStateMachine->can($invoice, 'close')) {
    $this->invoiceStateMachine->apply($invoice, 'close');
    }
    } catch (NotEnabledTransitionException $e) {
    dd($e->getTransitionBlockerList());
    }
    return $this->redirectToRoute('app_list_invoices');
    }
    }

    View Slide

  48. 48

    View Slide

  49. Metadata
    Contextual Data
    49

    View Slide

  50. 50
    “ The Workflow component
    enables state machines to store
    arbitrary metadata at the
    workflow, places and transitions
    level. Metadata can then be
    retrieved at runtime for use. ”

    View Slide

  51. # config/packages/workflow.yaml
    framework:
    workflows:
    invoice:
    ...
    metadata:
    title: Invoice Business Workflow
    places:
    draft:
    metadata:
    goods_vat_rate: 15
    services_vat_rate: 20
    transitions:
    - name: pay_half
    from: due
    to: due
    metadata:
    supported_currencies: [EUR, USD, CAD]
    supported_gateways: [STRIPE, PAYPAL]
    minimum_total_amount: 10000 51

    View Slide

  52. final class PayInvoiceGuardSubscriber implements EventSubscriberInterface
    {
    public function onGuardPayHalfTransition(GuardEvent $event): void
    {
    $invoice = $event->getSubject();
    \assert($invoice instanceof Invoice);
    $minTotalAmount = $event->getMetadata('minimum_total_amount', $event->getTransition());
    \assert(\is_int($minTotalAmount));
    $minTotalAmount = new Money($minTotalAmount, $invoice->getCurrency());
    if ($invoice->getTotalNetAmount()->lessThan($minTotalAmount)) {
    $event->setBlocked(true, 'Total amount is too low to enable half down payment!');
    return;
    }
    ...
    }
    }
    52

    View Slide

  53. final class PayHalfInvoiceController extends AbstractController
    {
    ...
    public function __invoke(Invoice $invoice): Response
    {
    $title = $this->invoiceStateMachine
    ->getMetadataStore ()
    ->getWorkflowMetadata()['title'] ?? 'Default title';
    $goodsVatRate = $this->invoiceStateMachine
    ->getMetadataStore ()
    ->getPlaceMetadata('draft')['goods_vat_rate'] ?? 0;
    $t = $this->invoiceStateMachine ->getDefinition()->getTransitions ()[0];
    $paymentGateways = $this->invoiceStateMachine
    ->getMetadataStore ()
    ->getTransitionMetadata($t)['supported_gateways'] ?? [];
    }
    } 53

    View Slide

  54. 54
    “ The Workflow component also
    enables state machines transitions
    to receive arbitrary contextual
    data when the transition is being
    tested or performed. ”

    View Slide

  55. Contextual Data
    55
    $stateMachine->can($subject, 'transition_name', [
    'some_data' => 'some_value',
    'other_data' => 'other_value',
    ]);
    $stateMachine->apply($subject, 'transition_name', [
    'some_data' => 'some_value',
    'other_data' => 'other_value',
    ]);

    View Slide

  56. 56
    final class DisputeInvoiceController extends AbstractController
    {
    public function __construct (
    private readonly StateMachine $invoiceStateMachine ,
    ) {
    }
    public function __invoke(Request $request, Invoice $invoice): Response
    {
    $user = $this->getUser();
    \assert( $user instanceof User);
    $dispute = new InvoiceDisputeRequest( $invoice, $user);
    $form = $this->createForm(InvoiceDisputeRequestType ::class, $dispute);
    $form->handleRequest ($request);
    if ($form->isSubmitted () && $form->isValid()) {
    // handle workflow transition
    }
    return $this->render('invoice/dispute.html.twig' , [
    'form' => $form->createView(),
    'invoice' => $invoice,
    'author' => $user,
    ]);
    }

    View Slide

  57. 57
    final class DisputeInvoiceController extends AbstractController
    {
    public function __invoke(Request $request, Invoice $invoice): Response
    {
    ...
    $dispute = new InvoiceDisputeRequest($invoice, $user);
    ...
    if ($form->isSubmitted() && $form->isValid()) {
    try {
    $this->invoiceStateMachine->apply($invoice, 'dispute', [
    'dispute_request' => $dispute,
    ]);
    $this->addFlash('success', 'Dispute has been opened.');
    return $this->redirectToRoute('app_list_invoices', [
    'id' => (string) $invoice->getId(),
    ]);
    } catch (NotEnabledTransitionException $e) {
    $this->mapBlockersToFormErrors($form, $e->getTransitionBlockerList());
    }
    }
    ...
    }
    }

    View Slide

  58. 58
    final class InvoiceLifecycleSubscriber implements EventSubscriberInterface
    {
    public static function getSubscribedEvents (): array
    {
    return [
    ...
    'workflow.invoice.entered.dispute' => 'onDisputedPlaceWasEntered',
    ];
    }
    ...
    public function onDisputedPlaceWasEntered(EnteredEvent $event): void
    {
    $request = $event->getContext()['dispute'];
    \assert( $request instanceof InvoiceDisputeRequest);
    ...
    }
    }

    View Slide

  59. 59
    final class InvoiceLifecycleSubscriber implements EventSubscriberInterface
    {
    ...
    public function onDisputedPlaceWasEntered(EnteredEvent $event): void
    {
    $request = $event->getContext()['dispute'];
    \assert($request instanceof InvoiceDisputeRequest);
    $invoice = $this->getInvoice($event);
    \assert($invoice->equals($request->invoice));
    $dispute = new InvoiceDispute(
    $request->invoice,
    $request->author,
    $request->message,
    );
    $this->entityManager->persist($dispute);
    $invoice->setLastDispute($dispute);
    }
    }

    View Slide

  60. Debugging
    60

    View Slide

  61. final class InvoiceLifecycleSubscriber implements EventSubscriberInterface
    {
    ...
    public function onInvoiceCompletedTransition
    (CompletedEvent $event): void
    {
    $this->entityManager->flush();
    $invoice = $event->getSubject();
    \assert($invoice instanceof Invoice);
    dump($invoice, $event->getTransition()?->getName());
    if ($event->getTransition()?->getName() == 'dispute') {
    $this->notifyDisputeCreated
    ($invoice->getLastDispute());
    } elseif ($event->getTransition()?->getName() === 'accept_dispute') {
    $this->notifyDisputeAccepted
    ($invoice->getDisputes()->last());
    }
    }
    }
    61

    View Slide

  62. 62

    View Slide

  63. 63

    View Slide

  64. 64
    $ symfony console debug:event-dispatcher workflow

    View Slide

  65. Playing Nicely
    with Others
    65

    View Slide

  66. 66
    Validator

    View Slide

  67. 67
    use Symfony\Component\Validator\Validator\ValidatorInterface;
    ...
    final class InvoiceGuardSubscriber implements EventSubscriberInterface
    {
    public static function getSubscribedEvents
    (): array
    {
    return [
    'workflow.invoice.guard.dispute' => 'onGuardDisputeTransition',
    ];
    }
    public function __construct(
    private readonly ValidatorInterface $validator,
    ) {
    }
    public function onGuardDisputeTransition(GuardEvent $event): void
    {
    ...
    }
    }

    View Slide

  68. final class InvoiceGuardSubscriber implements EventSubscriberInterface
    {
    ...
    /**
    * @return string[]
    */
    private function getValidationGroups (GuardEvent $event): array
    {
    if ($groups = ($event->getContext()['validation_groups' ] ?? [])) {
    return $groups;
    }
    return $event->getMetadata('validation_groups' ,
    $event->getTransition())
    ?? [];
    }
    }
    68

    View Slide

  69. use Symfony\Component\Validator\ConstraintViolation;
    use Symfony\Component\Workflow\TransitionBlocker;
    ...
    final class InvoiceGuardSubscriber implements EventSubscriberInterface
    {
    ...
    public function onGuardDisputeTransition(GuardEvent $event): void
    {
    $invoice = $event->getSubject();
    \assert($invoice instanceof Invoice);
    $groups = $this->getValidationGroups($event);
    $violations = $this->validator->validate($invoice, groups: $groups);
    ...
    /** @var ConstraintViolation $violation */
    foreach ($violations as $violation) {
    $event->addTransitionBlocker(new TransitionBlocker(
    $violation->getMessage(),
    $violation->getCode(),
    $violation->getParameters(),
    ));
    }
    }
    }
    69

    View Slide

  70. 70
    Messenger

    View Slide

  71. final class InvoiceLifecycleSubscriber implements EventSubscriberInterface
    {
    public static function getSubscribedEvents (): array
    {
    return [
    ...
    'workflow.invoice.completed.issue' => ['onInvoiceCompletedIssueTransition', 512],
    ];
    }
    public function __construct (
    ...
    private readonly MessageBus $messageBus,
    ) {
    }
    public function onInvoiceCompletedIssueTransition (CompletedEvent $event): void
    {
    $invoice = $this->getInvoice($event);
    $this->messageBus->dispatch(new GenerateInvoicePdfMessage($invoice->getId()));
    }
    }
    71

    View Slide

  72. ...
    #[AsMessageHandler]
    final class GenerateInvoicePdfMessageHandler
    {
    public function __construct(
    private readonly InvoiceRepository $invoiceRepository,
    private readonly InvoiceFileGenerator $invoiceFileGenerator,
    ) {
    }
    public function __invoke(GenerateInvoicePdfMessage $message): void
    {
    if (!$invoice = $this->invoiceRepository->findById($message->getInvoiceId())) {
    throw new UnrecoverableMessageHandlingException('Invoice not found!');
    }
    if (!$invoice->getFile()) {
    $this->invoiceFileGenerator->generatePdf($invoice);
    }
    }
    }
    72

    View Slide

  73. 73

    View Slide

  74. Sylius
    74
    winzou_state_machine:
    sylius_payment:
    class: "%sylius.model.payment.class%"
    property_path: state
    graph: sylius_payment
    state_machine_class: "%sylius.state_machine.class%"
    states:
    cart: ~
    new: ~
    processing: ~
    completed: ~
    ...
    transitions:
    create:
    from: [cart]
    to: new
    process:
    from: [new]
    to: processing
    complete:
    from: [new, processing]
    to: completed
    ...

    View Slide

  75. Q&A
    75
    [email protected]

    @hhamon

    View Slide