State Machines for Complex Processes
Order processes, approval workflows, content publishing and ticket systems all have one thing in common: objects change their state according to defined rules. Implementing these transitions with if-constructs scattered across services builds up technical debt. The Symfony Workflow Component turns state machines into declarative configuration with full event support and visualization.
Table of Contents
- 1. Why the Symfony Workflow Component instead of manual if-logic
- 2. Core concepts: workflow, state machine and the difference
- 3. Configuring a workflow declaratively in YAML
- 4. Entities and objects as workflow subjects
- 5. Guards: conditional transitions with safety checks
- 6. Using workflow events for side effects
- 7. Using the workflow service in code
- 8. Testing workflows: unit and integration tests
- 9. Comparison: workflow vs. state machine vs. manual logic
- 10. Summary
- 11. FAQ
1. Why the Symfony Workflow Component instead of manual if-logic
Every PHP project that manages objects with states, orders, tickets, documents, user accounts, sooner or later grows a web of if ($order->getStatus() === 'pending') checks scattered across services and controllers. Who is allowed to trigger the transition? Which states may follow one another? What happens during the transition (email, log, notification)? These questions end up in methods like confirmOrder(), cancelOrder() and shipOrder(), each with its own state checks that nobody holds fully in their head. The Symfony Workflow Component solves this problem fundamentally: the allowed transitions are declared centrally, the code merely calls $workflow->apply($order, 'confirm'), and the component checks whether the transition is allowed.
The core benefit of the Symfony Workflow Component is explicitness. The entire state machine, all states, all transitions, all conditions, lives in a single YAML file (or PHP configuration). New developers on the team understand the process by reading the configuration, not by laboriously searching services for status checks. When a new transition is added, it is added in one place. When a transition should be removed, it is removed there, without hunting through the code for every spot that checks or triggers that transition.
Another decisive advantage: the Symfony Workflow Component offers built-in visualization via Graphviz or Mermaid. Running bin/console workflow:dump order | dot -Tpng -o order.png produces an automatically generated diagram of the state machine that is always in sync with the code. That is an enormous advantage when communicating with non-technical stakeholders: the business analyst sees the same diagram the developer implemented, with no manual updating.
2. Core concepts: workflow, state machine and the difference
The Symfony Workflow Component knows two types: workflow and state_machine. The difference is fundamental: a state machine allows an object to be in exactly one state at any given time. A workflow allows multiple simultaneous states (a so-called marking), similar to a Petri net model. For classic order processes, ticket systems and content publishing workflows, the state machine is the right model. For parallel processes, where a document can simultaneously be "under review" and "pending payment", the workflow type is a better fit.
The core terms of the Symfony Workflow Component: a place is a state (e.g. pending, paid, shipped). A transition is a named transition between states (e.g. pay moves from pending to paid). The marking is the object's current position in the workflow, a single state for state machines, or a set of places for workflows. The marking store is the mechanism for how the marking is stored on the object, by default a single property for state machines or a JSON array for workflows. The subject is the PHP object (entity, DTO) that navigates through the workflow.
The choice between workflow and state machine in the Symfony component directly affects the database layer: with state machines, a single string column for the current status is sufficient. With workflows, the marking must be stored as JSON or in a separate table. For 95% of typical business processes, the state machine is the right approach, simpler to model, simpler to understand and simpler to map in the database.
3. Configuring a workflow declaratively in YAML
Configuring a Symfony Workflow Component in YAML is the heart of the approach. The structure is clear: under framework.workflows, you define for each process a name, the subject's class (supports), the type (type: state_machine), the states (places), the initial state (initial_marking) and the transitions (transitions). Each transition has a name, one or more source places (from) and one or more target places (to).
The Symfony Workflow Component also supports PHP-based configuration for projects that prefer YAML-free setups. The PHP configuration in config/packages/workflow.php carries the same information but is type-safe and refactorable. For complex workflows with many transitions, PHP configuration is often more readable than nested YAML. Symfony Flex creates a sample configuration file when the component is installed, which serves as a starting point.
<?php
// config/packages/workflow.yaml - Order state machine configuration
// framework:
// workflows:
// order_process:
// type: state_machine
// marking_store:
// type: method
// property: status # calls $order->setStatus() / $order->getStatus()
// supports:
// - App\Entity\Order
// initial_marking: pending
// places:
// - pending
// - confirmed
// - paid
// - processing
// - shipped
// - delivered
// - cancelled
// - refunded
// transitions:
// confirm:
// from: pending
// to: confirmed
// pay:
// from: confirmed
// to: paid
// process:
// from: paid
// to: processing
// ship:
// from: processing
// to: shipped
// deliver:
// from: shipped
// to: delivered
// cancel:
// from: [pending, confirmed]
// to: cancelled
// refund:
// from: [paid, processing, shipped, delivered]
// to: refunded
// PHP equivalent - same config, type-safe:
// return static function (FrameworkConfig $framework): void {
// $workflow = $framework->workflows()->workflow('order_process');
// $workflow->type('state_machine');
// $workflow->supports([Order::class]);
// $workflow->initialMarking(['pending']);
// // ... places and transitions
// };
4. Entities and objects as workflow subjects
The object that navigates through the Symfony Workflow must satisfy two conditions: it must match one of the classes declared in supports, and the marking store must be able to access its property. The default marking store of the Symfony Workflow Component is the method store: it calls $object->setStatus($state) and $object->getStatus(), matching a typical Doctrine entity with a status column. The property store pattern accesses a public property directly, without getters/setters.
For Doctrine entities, an enum type for the status column is recommended over a plain string. Symfony Workflow works with strings as state identifiers, but PHP 8.1 backed enums can be converted easily: the entity's getter/setter methods convert between the enum value and the string. That way the state is stored in the database as an enum column with a constraint, and PHP code always references the enum type, no more magic string comparisons in application code.
When several workflows run on the same object type, for example an order workflow and a separate return workflow for the same Order entity, you configure multiple Symfony Workflows with different status properties. The Order entity then gets two fields: $orderStatus for the order workflow and $returnStatus for the return workflow. The Symfony Workflow Component loads the matching workflow based on the supports configuration, or you specify the workflow name explicitly when several workflows support the same class.
5. Guards: conditional transitions with safety checks
Guards in the Symfony Workflow Component are event listeners that react to the GuardEvent and decide whether a transition is currently allowed. They augment the declarative transition configuration with dynamic conditions that depend on object state, user permissions or external factors. The Symfony Workflow system fires a guard event before every transition, which the listener can block. If the listener calls $event->setBlocked(true), then $workflow->can($order, 'ship') fails, and calling $workflow->apply($order, 'ship') throws a NotEnabledTransitionException.
Typical guard use cases in Symfony Workflow: a shipping transition (ship) may only run if the delivery address is fully filled in. An approval transition (approve) is only allowed for users with the ROLE_MANAGER role. A payment transition is only possible if the payment provider has actually confirmed success. Guards check these conditions without duplication: the same guard class becomes active for every code path that triggers the transition, whether via controller, CLI command or automated background process.
Guards are configured as Symfony services with the event listener tag, or carry the #[AsEventListener] attribute. The event name follows the pattern workflow.{workflow_name}.guard.{transition_name} for specific guards, or workflow.{workflow_name}.guard for guards that apply to every transition of a workflow. The expression attribute in the workflow YAML configuration also enables simple guards directly in the configuration via the Symfony ExpressionLanguage, without a dedicated PHP class, for conditions like "is_granted('ROLE_ADMIN')".
<?php
declare(strict_types=1);
namespace App\Workflow\Guard;
use App\Entity\Order;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Workflow\Event\GuardEvent;
/**
* Guard that blocks the 'ship' transition if the delivery address is incomplete.
* Also blocks 'approve' transitions for non-admin users.
*/
final readonly class OrderWorkflowGuard
{
public function __construct(
private AuthorizationCheckerInterface $authorizationChecker,
) {}
/**
* Prevent shipping if the delivery address is missing required fields.
*/
#[AsEventListener(event: 'workflow.order_process.guard.ship')]
public function guardShipTransition(GuardEvent $event): void
{
/** @var Order $order */
$order = $event->getSubject();
if ($order->getDeliveryAddress() === null) {
$event->setBlocked(true, 'No delivery address set.');
return;
}
if (!$order->getDeliveryAddress()->isComplete()) {
$event->setBlocked(true, 'Delivery address is incomplete.');
}
}
/**
* Only allow managers and admins to approve orders.
*/
#[AsEventListener(event: 'workflow.order_process.guard.confirm')]
public function guardConfirmTransition(GuardEvent $event): void
{
if (!$this->authorizationChecker->isGranted('ROLE_MANAGER')) {
$event->setBlocked(true, 'Only managers can confirm orders.');
}
}
/**
* Block cancellation of orders that already have a shipment tracking number.
*/
#[AsEventListener(event: 'workflow.order_process.guard.cancel')]
public function guardCancelTransition(GuardEvent $event): void
{
/** @var Order $order */
$order = $event->getSubject();
if ($order->getTrackingNumber() !== null) {
$event->setBlocked(true, 'Orders that have already shipped cannot be cancelled.');
}
}
}
6. Using workflow events for side effects
The Symfony Workflow Component fires several events during every transition that can be used for side effects, emails, notifications, audit logs, external API calls and cache invalidation. The events follow a fixed lifecycle: workflow.guard (permission check), workflow.leave (object leaves the source state), workflow.transition (transition is executed), workflow.enter (object enters the target state), workflow.entered (object is now in the new state), workflow.completed (transition finished), workflow.announce (announce enabled follow-up transitions).
For the order process, that means: an event listener on workflow.order_process.entered.paid automatically sends a payment confirmation email as soon as the order reaches the paid state. Another listener on workflow.order_process.entered.shipped sends the tracking number by SMS. These listeners are independent of one another, easy to test, and can be disabled without touching the core of the Symfony Workflow. The publish-subscribe pattern keeps business logic and side effects cleanly separated.
The events are ordered by the principle of highest specificity: workflow.order_process.entered.paid fires only for the specific workflow and the specific target state. workflow.order_process.entered fires for every state change in the order workflow. workflow.entered fires for every workflow of every type. Listeners on the most specific event are the preferred choice, because they explicitly document which workflow transition triggers the effect. That makes the system self-documenting and prevents listeners from accidentally reacting to transitions of other workflows.
7. Using the workflow service in code
The Symfony Workflow service is injected via dependency injection into controllers, services or command handlers. Symfony automatically registers a service for every configured workflow under the name workflow.{name}, so workflow.order_process for the example workflow. Using the WorkflowInterface or the abstract Workflow type in the constructor together with the #[Target] attribute (Symfony 7), you select the specific workflow via named autowiring.
The most important methods of the Symfony Workflow service: $workflow->can($order, 'ship') checks whether the transition is allowed (guards included) without executing it. $workflow->apply($order, 'ship') executes the transition, fires all events and throws an exception if it is not allowed. $workflow->getEnabledTransitions($order) returns all currently allowed transitions, useful for UI elements that should only show allowed actions. $workflow->getMarking($order) returns the current state.
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Order;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
/**
* Service for managing order state transitions via the Symfony Workflow Component.
*/
final readonly class OrderWorkflowService
{
public function __construct(
// Named autowiring: selects the 'order_process' workflow specifically
#[Target('order_process')]
private WorkflowInterface $workflow,
) {}
/**
* Confirm the order if the transition is currently allowed.
*
* @throws NotEnabledTransitionException if the 'confirm' transition is blocked by a guard
*/
public function confirmOrder(Order $order): void
{
// Explicit check: provides a clear error message before attempting
if (!$this->workflow->can($order, 'confirm')) {
$blockedTransitions = $this->workflow->buildTransitionBlockerList($order, 'confirm');
throw new \DomainException(
'Order cannot be confirmed: ' .
implode(', ', array_map(fn($b) => $b->getMessage(), iterator_to_array($blockedTransitions)))
);
}
// Apply triggers guard, leave, transition, enter, entered, completed events
$this->workflow->apply($order, 'confirm');
}
/**
* Get all currently enabled transitions for the given order.
* Use this to build dynamic action menus in UI components.
*
* @return list<string> Names of enabled transitions
*/
public function getAvailableActions(Order $order): array
{
return array_map(
fn($transition) => $transition->getName(),
$this->workflow->getEnabledTransitions($order)
);
}
/**
* Check if the order is in a specific state.
*/
public function isInState(Order $order, string $place): bool
{
return $this->workflow->getMarking($order)->has($place);
}
}
8. Testing workflows: unit and integration tests
The Symfony Workflow Component is well testable on several levels. Unit tests for guards: instantiate the guard service directly, pass in a mock GuardEvent and check whether setBlocked(true) was called. Unit tests for event listeners: instantiate the listener, pass in a mock event and check whether the expected side effect (email sending, database operations) was triggered. These tests require no Symfony framework bootstrap.
Integration tests for the complete Symfony Workflow: with the Symfony kernel and the WorkflowInterface from the container, an order object is navigated through the entire workflow. You check which transitions are allowed after each step, whether guards block correctly and whether events fire correctly. The $workflow->getEnabledTransitions($order) method returns the allowed transitions as a list, which can be checked with assertCount and assertSame.
An important test case: invalid transitions. $this->expectException(NotEnabledTransitionException::class); $workflow->apply($order, 'ship'); ensures that a transition which is not allowed (because the state is wrong or a guard blocks it) actually throws an exception, rather than silently being ignored. The Symfony Workflow Component always throws an exception on an invalid transition, which significantly simplifies testability compared to manual if-logic that often signals no error at all.
9. Comparison: workflow vs. state machine vs. manual logic
Comparing the three approaches shows why the Symfony Workflow Component is the superior choice for complex processes, and when simpler approaches make more sense.
| Criterion | Manual if-logic | Symfony state machine | Symfony workflow |
|---|---|---|---|
| State overview | Scattered across the code | Central YAML configuration | Central YAML configuration |
| Simultaneous states | Complex to manage | Not supported | Natively supported |
| Visualization | Manual, always outdated | Automatic via Graphviz | Automatic via Graphviz |
| Testability | Logic scattered everywhere | Guards and listeners isolatable | Guards and listeners isolatable |
| Setup effort | None | YAML + marking store | YAML + marking store + JSON |
For simple objects with two or three states and clear, unchanging transitions, the Symfony Workflow Component is overhead. An article with the states draft and published and a single transition does not need the component, a simple boolean is enough. From four or more states, several possible transitions and dynamic guard conditions, the Symfony Workflow Component pays off measurably: the configuration is still readable after a week, test coverage is complete, and the visualization is always up to date.
Mironsoft
Symfony development, workflow architecture and process modeling
Modeling complex business processes in Symfony?
We model business processes as state machines with the Symfony Workflow Component, from order processing to approval workflows to content publishing processes, with full event integration and test coverage.
Workflow design
Modeling states, transitions and guards for your business processes and implementing them as Symfony configuration
Event integration
Implementing side effects like emails, notifications and audit logs decoupled via workflow events
Migration
Refactoring existing if-logic and status enums into clean Symfony Workflow state machines
10. Summary
The Symfony Workflow Component is the right choice as soon as an object has more than three states or transitions depend on dynamic conditions. The declarative YAML configuration makes the process readable for the whole team, automatic visualization via Graphviz is always in sync with the code, and the event system enables side effects without coupling them to the core transition logic. Guards keep permission and validation logic cleanly separated from the workflow configuration.
Getting started is remarkably simple: define a workflow in config/packages/workflow.yaml, bind the Symfony marking store to the entity property, and inject the workflow service via constructor injection into services. Guards and event listeners are added as normal Symfony services. Testing is possible in isolation at every level: unit tests for guards and listeners, integration tests for the full workflow run. The Symfony Workflow Component replaces fragile if-constructs with traceable, testable and visualizable state machines.
Symfony Workflow Component: the essentials at a glance
Configuration
Places, transitions and marking store declared in YAML or PHP. type: state_machine for an exclusive state, type: workflow for parallel states.
Guards
Event listeners on workflow.{name}.guard.{transition} block transitions dynamically. Permission checks and validation without coupling to the configuration.
Events
workflow.entered.{place}, workflow.completed etc. for side effects. Emails, notifications and audit logs without coupling to the transition logic.
Visualization
bin/console workflow:dump {name} | dot -Tpng -o graph.png automatically generates a diagram, always in sync with the YAML configuration.