yaml php 171 lines · 4 tabs

Guarding an Order State Transition with Symfony's Workflow Component and a Guard Listener

Shared by codesnips Aug 2026
4 tabs
framework:
    workflows:
        order:
            type: state_machine
            marking_store:
                type: method
                property: currentState
            supports:
                - App\Entity\Order
            initial_marking: cart
            places:
                - cart
                - pending_payment
                - paid
                - shipped
            transitions:
                checkout:
                    from: cart
                    to: pending_payment
                pay:
                    from: pending_payment
                    to: paid
                ship:
                    from: paid
                    to: shipped
4 files · yaml, php Explain with highlit

This snippet shows how the Symfony Workflow component enforces business rules on an order's lifecycle, using a state machine definition, a guard listener that can veto transitions, and a controller action that applies transitions safely.

The workflow.yaml tab configures a state_machine named order. Unlike a plain workflow, a state machine allows a subject to be in exactly one place at a time, which matches an order that is either cart, pending_payment, paid, or shipped. The marking_store is a method store bound to the entity's currentState property, so the component reads and writes state through getCurrentState() and setCurrentState(). Each transition names its from and to places, forming the only legal paths an order may take. Any transition not listed here is impossible by construction, which is the core value of modeling state explicitly rather than scattering if checks across the codebase.

The Order entity tab is the workflow subject. It exposes the marking accessors the store expects and carries the domain data the guard needs — total, paymentReference, and shippingAddress. Keeping this data on the entity lets guard logic stay declarative and testable.

The OrderTransitionGuard listener tab is where policy lives. It subscribes to workflow.order.guard.pay and workflow.order.guard.ship, the event names the component dispatches before a specific transition fires. Each handler receives a GuardEvent; calling setBlocked() with a reason vetoes the transition without throwing, so the component reports it as not applicable. The pay guard rejects a zero total and a missing paymentReference; the ship guard requires a shippingAddress. This centralizes the invariants that must hold at each hop.

The OrderController tab ties it together. It injects the order workflow via the Target attribute for autowiring by name. Before applying a transition it calls $workflow->can() to check both the current place and every guard; if that fails it surfaces the blocking reason from buildTransitionBlockerList(). Wrapping apply() in a try/catch around NotEnabledTransition guards against races where state changed between the check and the write. This pattern gives a single source of truth for legal transitions, keeps controllers thin, and makes new rules a matter of adding a guard rather than editing scattered conditionals.


Related snips

Share this code

Here's the card — post it anywhere.

Guarding an Order State Transition with Symfony's Workflow Component and a Guard Listener — share card
Link copied