Symfony Workflow Component for State Machines (2026 Guide)

Khimananda Oli 10 min read DevOps, Linux
Symfony Workflow Component for State Machines (2026 Guide)

By Khimananda Oli | Last reviewed: August 2026

Every application has objects with a lifecycle: an order moves from cart to paid to shipped, an article from draft to review to published, a ticket from open to resolved. The usual implementation is a status string plus a growing pile of if ($order->getStatus() === 'paid') checks scattered across controllers, listeners, and services. The Symfony Workflow component replaces that sprawl with an explicit state machine: you declare the places an object can be in, the transitions between them, and the rules that guard each transition, and the component enforces all of it in one place. This guide covers configuration, guards, events, Twig integration, and the workflow-vs-state-machine decision on Symfony 7, with the same production habits I apply to background job pipelines.

The Symfony Workflow component lets you define a state machine in YAML: a list of places (states), transitions between them, and a marking store that persists the current place on your entity. You then call $workflow->can() and $workflow->apply() instead of hand-written status checks, and hook guards and side effects into dispatched events.

Order state machine (one place at a time)draftpendingpaymentpaidshippeddeliveredcancelledsubmitpayshipdelivercancelcancelTransitions are the only way to change state; anything not drawn here is impossible.
The order lifecycle as a Symfony Workflow state machine: six places, six transitions, and no way to reach "shipped" without passing through "paid".

What is the Symfony Workflow component and when should you use it?

The component ships two engines behind one API. A state machine keeps a subject in exactly one place at a time, which fits 90% of business lifecycles: orders, invoices, subscriptions, content moderation. A workflow (a Petri net) lets a subject sit in several places simultaneously, which models parallel tracks such as "legal review" and "technical review" that must both complete before "approved". Pick state_machine unless you genuinely have concurrent places.

Reach for the component when you recognise these symptoms:

  • The same status check is duplicated in more than two places and they have started to disagree.
  • Bugs like "an order was refunded before it was paid" reach production because nothing blocked the illegal jump.
  • Product asks for a diagram of the lifecycle and nobody can produce one from the code.
  • Side effects (emails, stock reservation, audit rows) are attached to status changes by convention rather than by a hook.

Install it with Composer. In a Symfony 7 project the Flex recipe drops a config/packages/workflow.yaml skeleton for you:

composer require symfony/workflow

How do you configure a state machine in Symfony?

Configuration lives under framework.workflows. Each workflow names the classes it supports, where the current place is stored, the places, and the transitions. Here is a complete order state machine matching the diagram above:

# config/packages/workflow.yaml
framework:
    workflows:
        order:
            type: 'state_machine'
            audit_trail:
                enabled: '%kernel.debug%'
            marking_store:
                type: 'method'
                property: 'status'
            supports:
                - App\Entity\Order
            initial_marking: draft
            places:
                - draft
                - pending_payment
                - paid
                - shipped
                - delivered
                - cancelled
            transitions:
                submit:
                    from: draft
                    to: pending_payment
                pay:
                    from: pending_payment
                    to: paid
                ship:
                    from: paid
                    to: shipped
                deliver:
                    from: shipped
                    to: delivered
                cancel:
                    from: [pending_payment, paid]
                    to: cancelled

The method marking store calls getStatus() and setStatus() on the entity, so the subject needs a matching property. Keep it a plain string column so Doctrine can index it and your reporting queries stay simple:

// src/Entity/Order.php
#[ORM\Entity]
class Order
{
    #[ORM\Column(length: 32)]
    private string $status = 'draft';

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status, array $context = []): void
    {
        $this->status = $status;
    }
}

The optional $context argument receives whatever you pass to apply(), which is handy for stamping who triggered a transition. Because type: state_machine implies a single place, the component stores a bare string; a workflow type would store an array of place names instead, and you would map the column as json.

Applying transitions in a service

Symfony registers one WorkflowInterface service per workflow. Autowire it by naming the argument after the workflow with a StateMachine or Workflow suffix, or use the #[Target] attribute to be explicit:

// src/Service/OrderService.php
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\WorkflowInterface;

final class OrderService
{
    public function __construct(
        #[Target('order')] private WorkflowInterface $orderStateMachine,
    ) {}

    public function ship(Order $order, User $actor): void
    {
        if (!$this->orderStateMachine->can($order, 'ship')) {
            throw new \DomainException('Order cannot be shipped from its current state.');
        }

        $this->orderStateMachine->apply($order, 'ship', ['actor' => $actor->getId()]);
    }
}

can() answers "is this transition enabled right now", taking both the current place and every guard into account. apply() performs it and throws NotEnabledTransitionException if something blocked it in between, so you can skip the can() pre-check and catch the exception when the caller is an API endpoint. getEnabledTransitions() returns everything currently possible, which is exactly what a UI needs to render action buttons.

How do guards and events control a Symfony workflow?

Configuration says which transitions exist; guards say when they are allowed, and the other events let you attach side effects without polluting the entity. Every transition dispatches a fixed sequence of events, each with a generic name and a name scoped to the workflow and transition or place:

Event order for apply($order, 'pay')guardcan blockthe transitionleavepending_paymenttransitionpayenterpaid (beforemarking saved)enteredpaid (markingis updated)completedthen announceEvent names you can listen to (most specific wins for clarity):workflow.order.guard.payonly the pay transition of the order workflowworkflow.order.entered.paidany transition that lands in place "paid"workflow.order.completedevery completed transition in this workflowworkflow.guardevery guard check in every workflow (audit, RBAC)Use "entered" or "completed" for side effects: the new place is already stored, so a failure cannot leave the entity half-transitioned.
Symfony Workflow event sequence for a single transition, and the scoped event names used to hook guards and side effects.

Blocking a transition with a guard listener

A guard listener receives a GuardEvent and calls setBlocked() with a human-readable reason. Since Symfony 7.1 you can register it with the #[AsGuardListener] attribute instead of a service tag:

// src/Workflow/OrderGuards.php
use Symfony\Component\Workflow\Attribute\AsGuardListener;
use Symfony\Component\Workflow\Event\GuardEvent;

final class OrderGuards
{
    public function __construct(private StockChecker $stock) {}

    #[AsGuardListener(workflow: 'order', transition: 'ship')]
    public function guardShip(GuardEvent $event): void
    {
        /** @var Order $order */
        $order = $event->getSubject();

        if (!$this->stock->isReserved($order)) {
            $event->setBlocked(true, 'Stock has not been reserved for this order.');
        }
    }
}

When a guard blocks, can() returns false and apply() throws; call $workflow->buildTransitionBlockerList($order, 'ship') to get the reasons and show them to the user. For simple rules you can skip PHP entirely and write an expression in YAML: guard: "is_granted('ROLE_WAREHOUSE') and subject.getItems()|length > 0". The expression has access to subject, the security functions, and the transition metadata.

Attaching side effects

Side effects belong on entered or completed, not on the entity setter. This keeps the entity a plain data holder and makes each effect independently testable:

// src/Workflow/OrderSideEffects.php
use Symfony\Component\Workflow\Attribute\AsEnteredListener;
use Symfony\Component\Workflow\Event\EnteredEvent;

final class OrderSideEffects
{
    public function __construct(private MessageBusInterface $bus) {}

    #[AsEnteredListener(workflow: 'order', place: 'paid')]
    public function onPaid(EnteredEvent $event): void
    {
        $order = $event->getSubject();
        $this->bus->dispatch(new SendOrderConfirmation($order->getId()));
        $this->bus->dispatch(new ReserveStock($order->getId()));
    }
}

Dispatching Messenger messages rather than doing the work inline means a slow mail server never blocks the HTTP response, and a retry policy handles transient failures. If you are used to Laravel's queue-backed listeners, the shape is identical; the practices in our guide to background job processing transfer directly.

Rendering available actions in Twig

The Twig bridge exposes helpers so templates never guess at the lifecycle either:

{% for transition in workflow_transitions(order) %}
    <a class="btn btn-outline-primary btn-sm"
       href="{{ path('order_transition', {id: order.id, transition: transition.name}) }}">
        {{ workflow_metadata(order, 'title', transition) ?? transition.name|title }}
    </a>
{% endfor %}

{% if workflow_has_marked_place(order, 'cancelled') %}
    <span class="badge bg-danger">Cancelled</span>
{% endif %}

workflow_transitions() only returns enabled transitions, guards included, so a button never appears for an action the user cannot perform. The title comes from a metadata block in YAML, the same mechanism you use to attach descriptions, colours, or required roles to places and transitions.

State machine vs workflow: which type should you choose?

The two types share configuration and API but differ in semantics, and the wrong choice shows up as awkward configuration months later. Use the comparison below, then run php bin/console workflow:dump order | dot -Tsvg -o order.svg to generate a diagram straight from your YAML and confirm it matches what the business expects.

state_machine vs workflowtype: state_machinetype: workflowExactly one place at a timeMarking stored as a string columnSame transition name may leaveseveral places (cancel from 2 states)Orders, invoices, tickets, contentEasy to index and report onSeveral places simultaneously (Petri net)Marking stored as a JSON arrayA transition can fan out to manyplaces and join them back togetherParallel reviews, multi-team approvalsReporting needs JSON queriespaidshippeddraftlegal_reviewtech_reviewboth places marked at once, then joined by "approve"default choice for business lifecycles
Choosing between Symfony's state_machine and workflow types: single-place lifecycles versus concurrent places in a Petri net.

A few production rules that have saved me from repeated incidents:

  1. Wrap apply() and the flush in one transaction. If a completed listener writes an audit row and the flush fails, both should roll back together. Use $em->wrapInTransaction() in the service that applies the transition.
  2. Never call setStatus() directly outside the marking store. Add a PHPStan rule or an architecture test that fails the build if anything other than the workflow touches it; otherwise the state machine is only a suggestion.
  3. Log every transition. Enable audit_trail in dev, and in production attach a completed listener that records subject, transition, actor from $event->getContext(), and timestamp. Debugging "how did this order become cancelled" becomes a single query.
  4. Test transitions with the real workflow service. Boot the kernel in a functional test, build an entity in each place, and assert can() for every transition. It is a small table-driven test that catches most YAML regressions, in the same spirit as the CI approach in our testing in CI/CD guide.
  5. Migrate legacy status columns deliberately. Run a one-off command that maps old free-text statuses to the new place names, and reject unknown values loudly rather than silently coercing them to initial_marking.

Modern PHP makes all of this pleasant: readonly promoted properties for listeners, enums for place names if you prefer type safety over YAML strings, and the attribute-based listeners shown above. If your team is still on an older runtime, the features in our PHP 8.4 feature rundown are worth the upgrade before adopting this pattern widely.

Wrapping up

The Symfony Workflow component turns a fragile status string into an explicit, diagrammable, enforceable state machine. Declare places and transitions in YAML, keep the entity dumb with a method marking store, block illegal moves in guards, push side effects into entered and completed listeners, and let Twig render only the transitions a user can actually take. Start with the object whose lifecycle bugs cost you the most, dump the graph, and put it in front of the product owner; the conversation that follows is usually worth more than the code. If you want help designing state machines for a Symfony or Laravel platform, or auditing an existing one, see my architecture and backend services or get in touch.

Frequently Asked Questions

It models the lifecycle of an object as a state machine or workflow: you declare places, transitions, and guards in configuration, and the component enforces which state changes are allowed, dispatches events around each transition, and stores the current place on your entity through a marking store.

A state_machine keeps the subject in exactly one place at a time and stores a string. A workflow is a Petri net where the subject can occupy several places simultaneously, stored as an array, which suits parallel tracks like concurrent reviews that must all finish before the next step.

Run composer require symfony/workflow. In a Flex-enabled project the recipe creates config/packages/workflow.yaml where you define your workflows.

The method marking store calls a getter and setter named after the configured property on your entity, for example getStatus() and setStatus(). For a state machine the value is a string; for a workflow type it is an array of place names, so map the column as string or json accordingly.

Call $workflow->can($subject, 'transitionName'). It returns true only when the subject is in a valid source place and no guard listener or guard expression blocked the transition.

apply() throws a NotEnabledTransitionException. You can catch it in an API controller and convert it to a 409 or 422 response, or call buildTransitionBlockerList() first to collect the human-readable reasons from guards and show them to the user.

A guard listener receives a GuardEvent before a transition and can call setBlocked(true, 'reason'). Register it with the #[AsGuardListener] attribute or the kernel.event_listener tag on events like workflow.order.guard.ship. Guards run for both can() and apply(), so the same rule protects the UI and the service layer.

Yes. Add a guard key to a transition with an ExpressionLanguage string such as is_granted('ROLE_MANAGER') and subject.getTotal() > 0. The expression has access to the subject, security helpers like is_granted and is_authenticated, and transition metadata, which is enough for most role and simple-field checks.

Use entered or completed. By then the new marking has been written to the subject, so a failing side effect cannot leave the entity half-transitioned. Dispatch a Messenger message from the listener so slow work such as email or stock reservation runs asynchronously with retries.

Type-hint WorkflowInterface and either name the argument after the workflow with a StateMachine or Workflow suffix, for example $orderStateMachine, or use the #[Target('order')] attribute for an explicit binding. A Registry service is also available when you need to pick the workflow at runtime.

Run php bin/console workflow:dump order and pipe the output to Graphviz with dot -Tsvg -o order.svg. Newer versions can also emit Mermaid syntax with --dump-format=mermaid, which renders directly in GitLab and GitHub markdown.

Use workflow_transitions(subject) to loop over enabled transitions and render a button for each, workflow_can(subject, 'name') for a single check, and workflow_has_marked_place(subject, 'place') to test the current state. These helpers respect guards, so disallowed actions never appear.

Yes. Set from to a list, for example from: [pending_payment, paid], and the transition is enabled from any of those places. This is the standard way to model a cancel action available at several stages of a state machine.

Enable audit_trail in development for automatic logging, and in production add a completed listener that persists the subject id, transition name, actor from the event context, and timestamp to an audit table. Wrap the transition and flush in a database transaction so the audit row and the state change succeed or fail together.

Yes. Doctrine entities are the most common subjects. The marking store only needs a getter and setter for the configured property, and you persist the change with the usual flush. The component itself has no database dependency, so it also works with plain objects, documents, or DTOs.