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

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

  2. 5

  3. 6

  4. 7

  5. 8

  6. 9

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

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

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

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

    cancel from: [draft, reviewing, due, paid] to: canceled 20
  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; } 26
  15. 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' ); } }
  16. 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' ); } }
  17. 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)
  18. 33 <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. 35 “ The Workflow component dispatches series of internal events

    when places and transitions are reached. ”
  20. 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]
  21. 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
  22. 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
  23. 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: []
  24. 41 “ 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 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')"
  26. Subject Based Expression 43 framework: workflows: invoice: ... transitions: -

    name: dispute from: due to: disputed guard: "is_granted('ROLE_CUSTOMER') and subject.isDisputable()"
  27. Subject Based Expression 44 class Invoice { ... public function

    isDisputable(): bool { return $this->getTotalNetAmount()->isPositive() && $this->getTotalPaidAmount()->isZero(); } }
  28. Security Voter Based Expression 45 framework: workflows: invoice: ... transitions:

    - name: dispute from: due to: disputed guard: "is_granted('INVOICE_VIEW', subject)"
  29. 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!'); } } }
  30. 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'); } }
  31. 48

  32. 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. ”
  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 51
  34. 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
  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'] ?? []; } } 53
  36. 54 “ The Workflow component also enables state machines transitions

    to receive arbitrary contextual data when the transition is being tested or performed. ”
  37. 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', ]);
  38. 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, ]); }
  39. 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()); } } ... } }
  40. 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); ... } }
  41. 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); } }
  42. 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
  43. 62

  44. 63

  45. 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 { ... } }
  46. 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
  47. 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
  48. 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
  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); } } } 72
  50. 73

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