
Table of Contents
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.
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:
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.
A few production rules that have saved me from repeated incidents:
- Wrap
apply()and the flush in one transaction. If acompletedlistener writes an audit row and the flush fails, both should roll back together. Use$em->wrapInTransaction()in the service that applies the transition. - 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. - Log every transition. Enable
audit_trailin dev, and in production attach acompletedlistener that records subject, transition, actor from$event->getContext(), and timestamp. Debugging "how did this order become cancelled" becomes a single query. - 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. - 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.