State Machines Across Multiple Entities: When They Burn and How to Stop the Fire
A field guide for teams using Symfony Workflow (or any state machine library) across more than one entity. Written after debugging exactly the kind of fire this article is meant to prevent.
The examples use Symfony Workflow, but the patterns are language-agnostic - they apply just as well to Ruby's AASM, Django's FSM libraries, or any state-machine engine.
The smell
Your Sentry inbox lights up with NotEnabledTransitionException. You dig in,
and the stack trace tells a story:
- User clicks "I want to wait for another term."
Applicanttransitions toWAITFORTERM.- The applicant subscriber cancels all of the applicant's reservations.
- The reservation subscriber, in turn, decides the applicant should be
WAITFORTERM. - Guard says no - applicant is already there. Exception thrown. Sentry pings.
The guard saved you from an infinite loop. Good. But the exception is noise, and worse, the noise is masking the actual issue: two state machines are reaching into each other's pockets and neither one owns the cross-cutting rule.
If you have two entities and one cross-entity rule, you can probably ignore
this article and just add a can() check. If you have three, four, or more
entities with a web of interactions between them - keep reading. This pattern
gets worse fast.
What's actually wrong
A state machine has one job: given the current state and an event, decide
the next state for this entity. That's it. It doesn't decide what other
entities should do. The moment your Applicant workflow's listener says
"and also, cancel all reservations," and your Reservation workflow's
listener says "and also, set the applicant to WAITFORTERM," you've created
a bidirectional dependency between two state machines.
That's the cycle. The exception is just the symptom.
The deeper problem is that the rule "when an applicant waits for term, their reservations are cancelled, and an applicant with no active reservations should be in WAITFORTERM" is one piece of business knowledge split across two files. Neither file owns it. Both files partially enforce it. Reading either one alone tells you half the story.
This doesn't scale. Add a third entity (say, ShowingInvitation) with its
own cross-cutting rules and you have a graph of mutual dependencies that
nobody can keep in their head.
The fix, in two parts
Part 1: Stop the bleeding (idempotency)
Wrap every apply() in a can() check. Always. No exceptions.
if ($workflow->can($subject, $transition)) {
$workflow->apply($subject, $transition);
}
This makes cross-entity cascades idempotent: if the cascade re-enters a listener that tries to put an entity into a state it's already in, the call silently no-ops instead of throwing.
If you have a helper method that wraps apply() and throws on failure (very
common pattern - usually called something like applyStatusTransition()),
split it in two:
// For user-initiated transitions. Loud failure on invalid state - that's
// a real bug or a malicious request.
public function applyStatusTransition(Applicant $a, string $transition): void
{
if (!$this->stateMachine->can($a, $transition)) {
throw new UserError(sprintf(
'Cannot transition from "%s" via "%s"',
$a->getStatus()->value,
$transition,
));
}
$this->stateMachine->apply($a, $transition);
}
// For cascade-triggered transitions. Silent no-op on invalid state - the
// target entity is already where we wanted it, which is the healthy
// outcome of a cascade winding down.
public function applyStatusTransitionIfPossible(
Applicant $a,
string $transition,
): bool {
if (!$this->stateMachine->can($a, $transition)) {
return false;
}
$this->stateMachine->apply($a, $transition);
return true;
}
One function, two intents. Now the call site declares what kind of transition this is, and the error semantics match.
Rule of thumb: if the caller is a controller responding to a user action, use the throwing version. If the caller is a workflow listener propagating a cascade, use the idempotent version.
This alone will quiet your Sentry. You can stop here if your system is small.
Part 2: Name the orchestration (Process Manager pattern)
If you have three or more entities involved in cross-cutting rules, the idempotency fix is necessary but not sufficient. You still have logic fragmented across N subscribers.
The pattern with a name is Process Manager (sometimes called Coordinator, Mediator, or - when the steps can fail and need compensation - Saga). It's documented in canonical DDD literature and Enterprise Integration Patterns.
The idea: create one class that listens to events from all the involved workflows and owns all cross-entity decisions. The individual workflows become pure - they only know about their own entity's lifecycle and never reach into another workflow.
final class ApplicantReservationCoordinator
{
public function __construct(
private ApplicantService $applicantService,
private ReservationService $reservationService,
) {}
#[AsEventListener('workflow.applicant.entered.wait_for_term')]
public function onApplicantWaitingForTerm(EnteredEvent $event): void
{
$applicant = $event->getSubject();
foreach ($applicant->getActiveReservations() as $reservation) {
$this->reservationService->applyStatusTransitionIfPossible(
$reservation,
'cancel',
);
}
}
#[AsEventListener('workflow.reservation.entered.cancelled')]
public function onReservationCancelled(EnteredEvent $event): void
{
$reservation = $event->getSubject();
$applicant = $reservation->getApplicant();
if ($applicant->hasActiveReservations()) {
return;
}
// Idempotent: silently no-ops if applicant is already in
// WAITFORTERM (which is exactly what happens when this fires
// as part of a cascade started from the applicant side).
$this->applicantService->applyStatusTransitionIfPossible(
$applicant,
'wait_for_term',
);
}
}
What changed mechanically? Nothing. Same can() check, same apply() call,
same termination of the cascade.
What changed architecturally? The whole cross-entity rule now lives in one file. Future-you opens this class, reads it top to bottom, and understands the entire cross-cutting behavior. The individual workflows go back to being self-contained.
Choreography vs orchestration
What you have right now (or had, before this article) is choreography: each entity reacts to other entities directly, with no central coordinator. This works fine for two participants and a linear flow. It becomes a spiderweb at four or more.
What the Process Manager gives you is orchestration: a single named place that owns the cross-cutting decisions. The individual state machines become "dumb" participants - they know how to transition themselves and nothing more.
Neither pattern is universally right. Choreography has less coupling overhead when the system is small. Orchestration scales better when it grows. The question isn't "which one is correct" but "which one am I unintentionally already using, and is it the right one for the current scale?"
Logging: log changes, not non-changes
Once your cascades are idempotent, you'll have a lot of silent no-ops. The temptation is to log them all "just in case." Resist.
- Successful transitions: absolutely log these. Audit trail for state machines is genuinely valuable. Consider a dedicated table, not just the application log.
- Routine cascade no-ops (entity already in target state): don't log. This is the system working correctly. Logging it pollutes logs until nobody reads them anymore.
- Anomalous skips (entity in some unexpected state that blocks the transition for non-cascade reasons): debug-level log, not error. Worth investigating if you find a pattern; not worth Sentry-paging.
The principle: logs should record things a human would want to know about later. A cascade terminating cleanly isn't one of those things.
Side effects and the transaction boundary
This isn't a state machine problem - it's a general one - but workflows make it loud, so it's worth covering here.
Imagine a listener that sends an email when an applicant transitions to
WAITFORTERM. The transition fires, the email goes out, then later in
the same request something throws and Doctrine rolls back. The applicant
is not in WAITFORTERM in the database. But the email is already in
the user's inbox saying they are.
The principle: commit the database first, then perform side effects. Never the other way around.
Facts vs notifications
The useful mental model is to split everything a transition does into two categories:
- Facts are things that became true in your domain. Audit rows, status updates, counter increments, related entity changes. These belong inside the transaction - if it rolls back, they un-happen, which is correct. Writing a fact is part of the atomic change.
- Notifications are messages to the outside world that a fact occurred. Emails, webhooks, push notifications, external API calls, cache invalidations, search index updates. These belong after the commit - because they're communicating an already-durable truth, and they can't be un-sent if the transaction rolls back.
Once you have this distinction in your head, it's easy to know which side of the line any side effect goes on. "Log to our DB that we transitioned" - fact, stays in. "Send an email about it" - notification, gets deferred.
A simple deferred-effects service
For systems with a clear transaction boundary (controller, GraphQL resolver, command handler, message handler), the cleanest implementation is a small service that collects callables and flushes them after commit. Tie the flush to the transaction wrapper - wherever that lives in your app.
final class DeferredSideEffects
{
/** @var array<callable> */
private array $pending = [];
public function __construct(private LoggerInterface $logger) {}
public function defer(callable $sideEffect): void
{
$this->pending[] = $sideEffect;
}
public function flush(): void
{
$effects = $this->pending;
$this->pending = [];
foreach ($effects as $effect) {
try {
$effect();
} catch (\Throwable $e) {
// DB is already committed. Don't let one failed effect
// (e.g., mailer down) bubble up and look like the
// operation failed.
$this->logger->error('Deferred side effect failed', [
'exception' => $e,
]);
}
}
}
public function discard(): void
{
$this->pending = [];
}
}
Wire it into your transaction boundary. If you have a GraphQL resolver wrapper, it might look like:
public function resolve($args) {
$this->em->beginTransaction();
try {
$result = $this->handler->handle($args);
$this->em->flush();
$this->em->commit();
} catch (\Throwable $e) {
$this->em->rollback();
$this->deferredSideEffects->discard();
throw $e;
}
// Commit succeeded. Fire deferred effects outside the try/catch
// so a failed email doesn't roll back the (already-committed) DB.
$this->deferredSideEffects->flush();
return $result;
}
Usage from a workflow listener is then unchanged:
$this->deferredSideEffects->defer(
fn() => $this->mailer->sendWaitForTermNotification($applicant)
);
Things worth knowing
- Capture values at defer time, not at execute time. If your deferred closure reads entity state when it runs, that state has already been committed and may have been modified by something else by the time the closure executes. Pull out the values you need into local variables before deferring.
- The service must be the same instance for one request. Default Symfony DI gives you this (services are singletons). If you've done anything unusual with scoping, double-check that the listener and the resolver share one instance.
- Make logged failures actually visible. The service swallows
per-effect exceptions on purpose. Make sure that
logger->error()is routed to Sentry - you want loud signal when an email fails, just not at the cost of failing the user's request. - This doesn't survive a process crash between commit and side
effect. If PHP dies in the microsecond between
commit()and the email being sent, the email is lost. For most apps this is fine. For compliance-critical notifications (payment confirmations, regulatory alerts), you'd want a transactional outbox - write the side effect as a row in anoutboxtable inside the transaction, then a separate worker delivers it. Out of scope here, but worth knowing the term.
When postFlush is the right hook instead
If your app doesn't have an explicit transaction wrapper - every
endpoint just modifies entities and lets Symfony flush at the end -
the same pattern works with Doctrine's postFlush event hooked up to
call DeferredSideEffects::flush() automatically. The trade-off:
you give up control over the exact moment of dispatch, but you don't
need to remember to call flush() from a wrapper. Suitable for
simpler systems.
The general rule
If a side effect leaves your process, it shouldn't happen inside a transaction. Workflows are where this rule tends to get broken first, because they fire many listeners synchronously, but it applies everywhere - controllers, services, command handlers, all of it.
Intra-entity logic stays where it is
A natural reaction to the Process Manager pattern is "wait, so my state machines are just status fields with validation?" No.
Move out: cross-entity transitions, cross-entity side effects. Keep in: guards on transitions, intra-entity side effects (setting timestamps, updating denormalized fields on the same entity), validations, notifications about that entity.
The Applicant workflow should still answer the question "what can happen to an applicant?" completely and self-containedly. What it shouldn't answer is "what should happen to a reservation when an applicant changes?" That's a system-level question, and it belongs in the system-level orchestrator.
Practical checklist
When you sit down to refactor:
- Find every place you call
$workflow->apply(). Audit each one for a matching$workflow->can()check. Add it where missing. - If you have a wrapper service method that throws on
can() === false, split it into a throwing version (for user-initiated transitions) and an idempotent version (for cascade transitions). Update callers. - Identify your workflow event listeners that call
apply()on a different workflow than the one they're listening to. These are your cross-entity orchestration points. - Create a Coordinator class per business process (e.g.
ApplicantReservationCoordinator,ShowingLifecycleCoordinator). Move the cross-entity listeners into it. The original subscribers keep only intra-entity logic. - Audit listeners for side effects that leave the process (emails,
webhooks, push notifications, external API calls). Move them behind
a deferred-effects service backed by Doctrine's
postFlushevent, so they only fire after the transaction commits successfully. - Document the rules in
CLAUDE.md(or your team's equivalent) so future contributors - human or AI - don't accidentally re-create the cycle or re-introduce premature side effects.
When not to do this
Honest disclosure: this pattern is overkill for small systems. If you have:
- Two entities, one cross-cutting rule, and no plans to add more - just
use the
can()check and move on. - A single domain process that's truly simple ("order placed -> email sent" is not worth an orchestrator).
The Process Manager earns its keep when you have:
- Three or more entities with cross-cutting rules between them.
- Multiple distinct business processes touching overlapping entities.
- A team large enough that "knowledge fragmented across files" becomes a real onboarding cost.
If you're not there yet but can see it coming, lean into it before the cycles appear. The refactor is much harder once the spaghetti is cooked.
A CLAUDE.md snippet
If you're using Claude Code (or any AI coding assistant that reads project instruction files), add this to make future contributors aware of the pattern:
## Symfony Workflows - cross-entity rules
This project uses Symfony Workflow across multiple entities. Cross-entity
transitions can create event cycles.
### Rules
- Every `$workflow->apply()` MUST be preceded by `$workflow->can()`, OR
call the `applyStatusTransitionIfPossible()` wrapper.
- Use `applyStatusTransition()` (throwing) for user-initiated transitions.
- Use `applyStatusTransitionIfPossible()` (idempotent) for cascade
transitions inside workflow listeners.
- All cross-entity transition logic lives in `src/Workflow/Coordinator/`.
Per-entity subscribers should only contain intra-entity logic.
- Before adding a new listener that calls `apply()` on a different
workflow than the one it listens to: stop, and consider putting it in
a Coordinator instead.
- Side effects that leave the process (emails, webhooks, push
notifications, external API calls) MUST be deferred via the
`DeferredSideEffects` service so they only fire after the
Doctrine transaction commits. Never call mailers, HTTP clients, or
message dispatchers directly inside a workflow listener.
References
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013). Long-running processes and domain events.
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003). The original argument that cross-aggregate rules belong outside any single aggregate.
- Gregor Hohpe & Bobby Woolf, Enterprise Integration Patterns. Formal documentation of the Process Manager pattern.
- Microsoft .NET architecture guide on domain events: clear, citation-heavy treatment of side effects across aggregates. https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation
- Symfony Workflow component docs: https://symfony.com/doc/current/workflow (Note: the docs cover mechanics, not orchestration patterns. The patterns in this article are not Symfony-specific.)
The state machine doesn't burn because of any single mistake. It burns because the rule "what should happen when X changes" never had a home, so it spread across every listener that seemed plausible. Give the rule a home before it spreads.
About the author. Michal Campr is a backend engineer at one of Central Europe's largest real estate marketplaces, where he has built and maintained Symfony applications in production since 2016.