Upgrade to Pro — share decks privately, control downloads, hide ads and more …

State Machines with the Symfony Component

Hugo Hamon
February 18, 2025

State Machines with the Symfony Component

The Symfony Workflow component is a powerful and flexible library designed to model complex processes using finite state machines (FSM) or business workflows. It provides a structured approach for managing state transitions, events, and actions within an application. Workflow is often used to implement state-driven logic in applications like approval processes, content publishing, order processing, and much more.

The Symfony Workflow component can be extended through custom guards to control transition validity, custom event listeners for handling workflow stages, and custom marking stores for storing workflow states externally. It also allows the creation of custom Symfony commands for workflow management, integration with UI frameworks for visualizing workflow states, and linking workflows with other Symfony components like Forms or the Event Dispatcher to automate actions based on business logic.

The Symfony Workflow component provides a versatile solution for modeling state-driven processes, allowing easy tracking and management of states and transitions. With a variety of features, customization options, and extensibility points, it serves as a robust foundation for implementing finite state machines and workflows in any Symfony-based application.

Hugo Hamon

February 18, 2025
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Designing State Machines with the Symfony Workflow Component Hugo Hamon

    | PHP User Group, Bucharest 󰐬 - Feb. 18th 2025 | [email protected] - @hhamon 1
  2. 2

  3. 5

  4. 6

  5. 7

  6. 8

  7. 11 “ The Workflow component provides tools for managing a

    workflow or a finite state machine. ” — https://symfony.com
  8. 12 “ A state machine is a subset of a

    workflow and its purpose is to hold a state of your model. ” — https://symfony.com
  9. 13 “ 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
  10. # 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: [] 16
  11. # config/packages/workflow.yaml framework: workflows: invoice: ... places: - draft -

    reviewing - due - disputed - paid - canceled - archived 17
  12. # 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 } 18
  13. # config/packages/workflow.yaml framework: workflows: invoice: ... transitions: ... - name:

    cancel from: [draft, reviewing, due, paid] to: canceled 19
  14. 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; } 25
  15. 27 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' ); } }
  16. 29 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' ); } }
  17. Twig Workflow Functions 31 ★ 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)
  18. 32 <ul> {% if workflow_can(invoice, 'pay_half') %} <li> <a href="{{

    path('app_pay_half_invoice' , {id: invoice.id}) }}">Pay half</a> </li> {% endif %} {% if workflow_can(invoice, 'pay_full') %} <li> <a href="{{ path('app_pay_full_invoice' , {id: invoice.id}) }}">Pay full</a> </li> {% endif %} {% if workflow_can(invoice, 'dispute') %} <li> <a href="{{ path('app_dispute_invoice' , {id: invoice.id}) }}">Dispute</a> </li> {% endif %} </ul>
  19. 34 “ The Workflow component dispatches series of internal events

    when places and transitions are reached. ”
  20. Event System 35 ★ 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] Guard events / Place events / Transition events / Workflow events
  21. final class InvoiceLifecycleListener { ... #[AsEventListener(event: 'workflow.invoice.completed', priority: 1024)] public

    function onInvoiceCompletedTransition(CompletedEvent $event): void { $invoice = $event->getSubject(); \assert( $invoice instanceof Invoice); $this->entityManager->persist($invoice); $this->entityManager->flush(); } } 36
  22. final class InvoiceLifecycleListener { public function __construct( ... private readonly

    InvoiceDisputeMailer $disputeMailer, ) { } #[AsEventListener(event: 'workflow.invoice.completed.dispute', priority: 512)] public function onInvoiceCompletedDisputeTransition(CompletedEvent $event): void { $this->notifyDisputeCreated($this->getInvoice($event)->getDisputes()->last()); } #[AsEventListener(event: 'workflow.invoice.completed.accept_dispute', priority: 512)] public function onInvoiceCompletedAcceptDisputeTransition(CompletedEvent $event): void { $this->notifyDisputeAccepted($this->getInvoice($event)->getDisputes()->last()); } } 37
  23. Controlling Dispatched Events 38 framework: workflows: invoice: ... # Only

    dispatch these events events_to_dispatch: - workflow.leave - workflow.completed # Don’t dispatch any events # events_to_dispatch: []
  24. 40 “ Guards are designed as event listeners. They run

    some business logic aimed at controlling whether or not a certain transition can be performed. ”
  25. Role Based Security Expression 41 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')"
  26. Subject Based Expression 42 class Invoice { ... public function

    isDisputable(): bool { return $this->getTotalNetAmount()->isPositive() && $this->getTotalPaidAmount()->isZero(); } }
  27. Subject Based Expression 43 framework: workflows: invoice: ... transitions: -

    name: dispute from: due to: disputed guard: "is_granted('ROLE_CUSTOMER') and subject.isDisputable()"
  28. Security Voter Based Expression 44 framework: workflows: invoice: ... transitions:

    - name: dispute from: due to: disputed guard: "is_granted('INVOICE_VIEW', subject)"
  29. Guards - Custom Event Listener 45 final class PayInvoiceGuardListener {

    #[AsEventListener(event: 'workflow.invoice.guard.pay_half')] 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!'); } } }
  30. 46 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'); } }
  31. 47

  32. 49 “ 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. ”
  33. # 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 50
  34. final class PayInvoiceGuardListener { #[AsEventListener(event: 'workflow.invoice.guard.pay_half')] 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; } ... } } 51
  35. 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'] ?? []; } } 52
  36. 53 “ The Workflow component also enables state machines transitions

    to receive arbitrary contextual data when the transition is being tested or performed. ”
  37. Contextual Data 54 $stateMachine->can($subject, 'transition_name'); $stateMachine->apply($subject, 'transition_name', [ 'some_data' =>

    'some_value', 'other_data' => 'other_value', ]); ⚠ The Worflow::can() method does not accept contextual data ⚠
  38. 55 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, ]); }
  39. 56 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()); } } ... } }
  40. 57 final class InvoiceLifecycleListener { ... #[AsEventListener(event: 'workflow.invoice.entered.dispute')] public function

    onDisputedPlaceWasEntered(EnteredEvent $event): void { $request = $event->getContext()['dispute']; if (!$request instanceof InvoiceDisputeRequest) { throw new \LogicException(...); } ... } }
  41. 58 final class InvoiceLifecycleListener { ... #[AsEventListener(event: 'workflow.invoice.entered.dispute')] public function

    onDisputedPlaceWasEntered(EnteredEvent $event): void { // ... $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); } }
  42. final class InvoiceLifecycleListener { ... #[AsEventListener(event: 'workflow.invoice.completed')] 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()); } } } 60
  43. 61

  44. 62

  45. 66 use Symfony\Component\Validator\Validator\ValidatorInterface; ... final class InvoiceGuardListener { public function

    __construct( private readonly ValidatorInterface $validator, ) { } #[AsEventListener(event: 'workflow.invoice.guard.dispute')] public function onGuardDisputeTransition(GuardEvent $event): void { ... } }
  46. final class InvoiceGuardListener { ... /** * @return string[] */

    private function getValidationGroups (GuardEvent $event): array { return $event->getMetadata('validation_groups', $event->getTransition()) ?? []; } } 67
  47. final class InvoiceGuardListener { ... #[AsEventListener(event: 'workflow.invoice.guard.dispute')] public function onGuardDisputeTransition

    (GuardEvent $event): void { $invoice = $event->getSubject(); \assert( $invoice instanceof Invoice); $violations = $this->validator->validate( value: $invoice, groups: $this->getValidationGroups($event), ); ... /** @var ConstraintViolation $violation */ foreach ($violations as $violation) { $event->addTransitionBlocker(new TransitionBlocker( $violation->getMessage(), $violation->getCode(), $violation->getParameters(), )); } } } 68
  48. final class InvoiceLifecycleListener { public function __construct( ... private readonly

    MessageBus $messageBus, ) { } #[AsEventListener(event: 'workflow.invoice.completed.issue', priority 512)] public function onInvoiceCompletedIssueTransition (CompletedEvent $event): void { $invoice = $event->getSubject(); \assert($invoice instanceof Invoice); $this->messageBus->dispatch( new GenerateInvoicePdfMessage($invoice->getId()), ); } } 70
  49. ... #[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); } } } 71
  50. 72

  51. Sylius 73 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 ...