Michal Campr
EST · 2008
Articles

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:

  1. User clicks "I want to wait for another term."
  2. Applicant transitions to WAITFORTERM.
  3. The applicant subscriber cancels all of the applicant's reservations.
  4. The reservation subscriber, in turn, decides the applicant should be WAITFORTERM.
  5. 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.

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:

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

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:

  1. Find every place you call $workflow->apply(). Audit each one for a matching $workflow->can() check. Add it where missing.
  2. 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.
  3. 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.
  4. 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.
  5. 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 postFlush event, so they only fire after the transaction commits successfully.
  6. 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:

The Process Manager earns its keep when you have:

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


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.