Slide 1

Slide 1 text

Designing State Machines with the Symfony Workflow Component Hugo Hamon | PHP User Group, Bucharest 󰐬 - Feb. 18th 2025 | [email protected] - @hhamon 1

Slide 2

Slide 2 text

2

Slide 3

Slide 3 text

https://speakerdeck.com/hhamon 3

Slide 4

Slide 4 text

Introduction 4

Slide 5

Slide 5 text

5

Slide 6

Slide 6 text

6

Slide 7

Slide 7 text

7

Slide 8

Slide 8 text

8

Slide 9

Slide 9 text

9 States Steps Transitions Lifecycles Validation Business Logic

Slide 10

Slide 10 text

10 The Symfony Workflow Component

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

Installation & Configuration 14

Slide 15

Slide 15 text

$ composer require symfony/workflow 15

Slide 16

Slide 16 text

# 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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

# 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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

$ symfony console debug:autowiring workflow 21

Slide 22

Slide 22 text

Audit Trail 22

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

Usage 24

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

26 Workflow::apply()

Slide 27

Slide 27 text

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' ); } }

Slide 28

Slide 28 text

28 Workflow::can()

Slide 29

Slide 29 text

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' ); } }

Slide 30

Slide 30 text

Twig Integration 30

Slide 31

Slide 31 text

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)

Slide 32

Slide 32 text

32
    {% 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 %}

Slide 33

Slide 33 text

Extension Points 33

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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: []

Slide 39

Slide 39 text

Guard Controls 39

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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')"

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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!'); } } }

Slide 46

Slide 46 text

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'); } }

Slide 47

Slide 47 text

47

Slide 48

Slide 48 text

Metadata Contextual Data 48

Slide 49

Slide 49 text

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. ”

Slide 50

Slide 50 text

# 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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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 ⚠

Slide 55

Slide 55 text

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, ]); }

Slide 56

Slide 56 text

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()); } } ... } }

Slide 57

Slide 57 text

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(...); } ... } }

Slide 58

Slide 58 text

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); } }

Slide 59

Slide 59 text

Debugging 59

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

61

Slide 62

Slide 62 text

62

Slide 63

Slide 63 text

63 $ symfony console debug:event-dispatcher workflow

Slide 64

Slide 64 text

Playing Nicely with Others 64

Slide 65

Slide 65 text

65 Validator

Slide 66

Slide 66 text

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 { ... } }

Slide 67

Slide 67 text

final class InvoiceGuardListener { ... /** * @return string[] */ private function getValidationGroups (GuardEvent $event): array { return $event->getMetadata('validation_groups', $event->getTransition()) ?? []; } } 67

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

69 Messenger

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

... #[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

Slide 72

Slide 72 text

72

Slide 73

Slide 73 text

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 ...

Slide 74

Slide 74 text

Q&A 74 [email protected] – @hhamon