- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open any mid-sized PHP codebase and search for setStatus(. You'll find dozens of call sites. Some have guards. Some don't. Some throw, some return false, some silently overwrite a paid order with pending. Three months later a bug ticket lands: a refunded order somehow shipped.
Symfony has shipped a fix for this since 2015 and most teams have never opened the package. It's called symfony/workflow. Thirty lines of YAML replace the if/switch jungle, you get a free audit trail, and bin/console workflow:dump draws you a diagram you can paste into the architecture doc.
It also works outside Symfony. Install it in a Laravel app and you're done.
What the Workflow component is
symfony/workflow is a state-machine engine driven by configuration. You describe places (states) and transitions (named moves between places). The component enforces the rules. Your domain object stores a string for its current place. That's the whole shape.
There are two flavors:
-
state_machine: an object can be in exactly one place at a time. This is what you want for an order, a payment, a user account, a support ticket. -
workflow: an object can be in multiple places simultaneously. This is for parallel approval flows: legal reviewed AND finance reviewed AND security reviewed, then deploy.
If you're not sure, you want state_machine. The other one is for the 5% of cases.
The package: composer require symfony/workflow. PHP 8.1+. No Symfony framework required.
A real example: order lifecycle in YAML
Here's an Order entity. Nothing magical, just a property holding the current state:
<?php
namespace App\Domain\Order;
class Order
{
private string $state = 'cart';
private array $items = [];
private ?string $paymentId = null;
private ?\DateTimeImmutable $shippedAt = null;
public function getState(): string
{
return $this->state;
}
public function setState(string $state): void
{
// the workflow component calls this, not your business code
$this->state = $state;
}
public function getItems(): array { return $this->items; }
public function getPaymentId(): ?string { return $this->paymentId; }
}
And the workflow config (config/packages/workflow.yaml):
framework:
workflows:
order_lifecycle:
type: state_machine
audit_trail:
enabled: true
marking_store:
type: method
property: state
supports:
- App\Domain\Order\Order
initial_marking: cart
places:
- cart
- placed
- paid
- fulfilled
- cancelled
- refunded
transitions:
place_order:
from: cart
to: placed
pay:
from: placed
to: paid
fulfill:
from: paid
to: fulfilled
cancel_unpaid:
from: [cart, placed]
to: cancelled
refund:
from: paid
to: refunded
Now any code that wants to move an order calls the workflow:
use Symfony\Component\Workflow\Registry;
class CheckoutService
{
public function __construct(private Registry $workflows) {}
public function placeOrder(Order $order): void
{
$workflow = $this->workflows->get($order, 'order_lifecycle');
if (!$workflow->can($order, 'place_order')) {
// the workflow tells you WHY it can't transition
throw new \DomainException(
'Cannot place order from state: ' . $order->getState()
);
}
$workflow->apply($order, 'place_order');
}
}
apply() does the move. If the transition is illegal (say someone tries pay on a cancelled order), it throws NotEnabledTransitionException with the exact reason. No more silent state corruption. No more "wait, how did that order end up paid AND cancelled?"
Transitions and guards: putting business rules in event listeners
The interesting work is the guard. A transition can be structurally legal (you're in placed and want to go to paid) but business-illegal (the order total is zero, or the customer's been flagged for fraud). Guards run before the move and can veto it.
You hook a guard via an event listener. Symfony fires workflow.order_lifecycle.guard.pay right before applying pay, and any listener can call $event->setBlocked(true, 'reason'):
<?php
namespace App\Workflow\Order;
use App\Domain\Order\Order;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Workflow\Event\GuardEvent;
class PayGuardListener
{
public function __construct(
private FraudCheckService $fraud,
private PriceCalculator $prices,
) {}
#[AsEventListener(event: 'workflow.order_lifecycle.guard.pay')]
public function onGuardPay(GuardEvent $event): void
{
/** @var Order $order */
$order = $event->getSubject();
if (count($order->getItems()) === 0) {
$event->setBlocked(true, 'order has no items');
return;
}
$total = $this->prices->total($order);
if ($total->isZero()) {
$event->setBlocked(true, 'order total is zero, refusing to charge');
return;
}
if ($this->fraud->isFlagged($order)) {
// we let support reverse this; we don't auto-fail silently
$event->setBlocked(true, 'fraud check failed');
}
}
}
The autoconfigure attribute (#[AsEventListener]) needs Symfony 6.3+. On older versions you tag the service manually. The event name is templated: workflow.<workflow_name>.guard.<transition_name>. There are also leave, transition, enter, and entered events if you need finer hooks.
The shape here is worth noticing: the guard is one class doing one thing. There's no if ($order->status === 'placed') anywhere. The when is implicit in the event name. The why is the listener's body.
Audit log for free
Set audit_trail.enabled: true in the workflow YAML and Symfony logs every transition through the logger service. That's fine for debugging, useless for compliance.
For the real audit log you want (who moved which order from where to where and when), write a subscriber on the entered event:
<?php
namespace App\Workflow\Order;
use App\Domain\Audit\AuditEntry;
use App\Domain\Audit\AuditRepository;
use App\Domain\Order\Order;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Workflow\Event\Event;
class OrderAuditSubscriber implements EventSubscriberInterface
{
public function __construct(
private AuditRepository $audit,
private TokenStorageInterface $tokens,
) {}
public static function getSubscribedEvents(): array
{
// listens to every "entered" on the order workflow
return [
'workflow.order_lifecycle.entered' => 'onEntered',
];
}
public function onEntered(Event $event): void
{
/** @var Order $order */
$order = $event->getSubject();
$token = $this->tokens->getToken();
$actor = $token?->getUserIdentifier() ?? 'system';
$transition = $event->getTransition();
$from = array_key_first($event->getMarking()->getPlaces());
// we record from the marking BEFORE entered fires, so capture in 'transition' if you need both
$this->audit->save(new AuditEntry(
subjectId: $order->getId(),
subjectType: Order::class,
transition: $transition?->getName() ?? 'unknown',
toPlace: $from,
actor: $actor,
at: new \DateTimeImmutable(),
));
}
}
Now every legitimate state change on every order writes a row. You get a free answer to "when did this order get refunded and who did it" without scattering audit calls through the codebase. The subscriber is the only place that writes audit rows, which means there's exactly one place to look when the auditor asks.
If you want the from place reliably, listen to workflow.order_lifecycle.transition instead. Event::getMarking() there still holds the old place. The naming is annoyingly subtle and the docs gloss over it. Test with a print statement on a dummy transition before you trust the row contents.
Rendering the diagram
This is the part that converts skeptics. Once your workflow is in YAML, you get:
bin/console workflow:dump order_lifecycle | dot -Tpng -o order_lifecycle.png
dot is Graphviz (brew install graphviz on macOS). The PNG that drops out is the diagram you'd otherwise have hand-drawn in Excalidraw and watched go stale within a week. This one regenerates from the source of truth on every PR. Stick it in CI, commit the SVG, and the architecture doc updates itself.
You can also output mermaid (--dump-format=mermaid) or PUML if you prefer those tools. The PR diff on a workflow change is +1, -1 in YAML and a visibly different diagram. Reviewers can see the state machine evolve.
Using it outside Symfony (yes, including Laravel)
The package name is symfony/workflow but the only Symfony framework dependency is the event dispatcher (symfony/event-dispatcher, which Laravel apps generally already have transitively). The component itself is framework-neutral PHP.
In Laravel:
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
use Symfony\Component\Workflow\StateMachine;
use Symfony\Component\Workflow\Transition;
$builder = new DefinitionBuilder();
$builder->addPlaces(['cart', 'placed', 'paid', 'fulfilled', 'cancelled', 'refunded']);
$builder->addTransition(new Transition('place_order', 'cart', 'placed'));
$builder->addTransition(new Transition('pay', 'placed', 'paid'));
$builder->addTransition(new Transition('fulfill', 'paid', 'fulfilled'));
$builder->addTransition(new Transition('cancel_unpaid', ['cart', 'placed'], 'cancelled'));
$builder->addTransition(new Transition('refund', 'paid', 'refunded'));
$definition = $builder->build();
$marking = new MethodMarkingStore(singleState: true, property: 'state');
$orderWorkflow = new StateMachine($definition, $marking);
// in a Laravel service provider, bind $orderWorkflow as a singleton
$this->app->instance('workflow.order', $orderWorkflow);
You lose the YAML config and the workflow:dump CLI integration unless you wire those up yourself, but the engine, the guards, the audit events: all of it works. Drop the event dispatcher in, register subscribers the Laravel way, and you've got the same machine.
Plenty of Laravel teams run symfony/workflow exactly because they don't want to invent another state-machine library. It's mature, it's covered by Symfony's BC promise, and it does one thing well.
When NOT to reach for it
Symfony Workflow is brilliant for bounded, mostly-static state machines: orders, support tickets, KYC approvals, content publishing, document lifecycle. Five to fifteen places, twenty-ish transitions, business rules in guards.
It's the wrong tool when:
- The transitions are dynamic. If admins create new states at runtime through a UI, you don't want them editing YAML. You want a database-backed state machine and a different abstraction.
-
The graph is gigantic. Workflows with 80 places and 200 transitions show up in the wild. The
workflow:dumpPNG becomes a wall poster nobody reads. At that point you're modeling a process engine and you should be looking at Camunda, Temporal, or a real BPMN tool, not Symfony Workflow. - You need long-running state with timers and retries. Workflow component is stateless logic over your data. It doesn't schedule "if this order isn't paid in 7 days, cancel it." That's a job for Symfony Messenger + a scheduler, or again, Temporal.
For the boring middle (the 90% of CRUD apps with a handful of states), symfony/workflow is the answer most teams don't know they have.
The takeaway
State machines belong in config, not in the bodies of service methods. The Symfony Workflow component gives you that for free, plus a guard hook, plus an audit subscriber, plus a diagram generator. It's one of the most underused packages in the PHP ecosystem. Install it, encode one workflow that currently lives as if/switch, and watch the diff: 200 lines of branching turn into 30 lines of YAML and three small classes.
The codebase gets calmer. The state diagram becomes a document a non-engineer can read. And the next time a refunded order tries to ship, the workflow throws.
What's the worst state-corruption bug you've seen ship to production because setStatus() was called from the wrong place? Drop it in the comments.
If this was useful
Symfony Workflow is one piece of a bigger shift: pulling business rules out of controllers and framework glue and into something a domain expert can read. The state machine, the guards, the audit subscriber. They're the kind of decoupled design that keeps a codebase shippable five years in. Decoupled PHP is the architectural layer your codebase reaches for after it outgrows the framework defaults: clean and hexagonal patterns applied to real PHP apps, not toy examples.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)