A state machine starts simple. A handful of states, a few transitions, everything fits on a whiteboard. Then the real requirements show up.
Your payment state isn't just one state anymore - it's a process with its own internal flow: selecting a method, processing the charge, waiting for confirmation, handling retries. Your flat state machine becomes a hierarchy. States contain states. This is what the statechart world calls “compound states” or “hierarchical states.”
Every statechart-based library supports this. XState, EventMachine, SCXML, you name it. And it works beautifully - until you notice something odd in your definitions.
Multiple child states need to respond to the same event in the same way. A failure event that should transition to failed. A cancellation event that should transition to cancelled. You write the same transition in every child. Identically. Three times. Five times. Eight times.
This is the “repeated transition problem.” It's not specific to any library, language, or framework. It shows up in every statechart implementation that supports nested states. And the solution is just as universal: event bubbling.
The examples in this article use EventMachine, a Laravel package for event-driven state machines. But everything here applies to any system where states live inside other states.
The Constant That Shouldn't Exist
You're reviewing a state machine definition. Four different states transition to failed when the same error event fires. A previous developer - maybe you three months ago - extracted the repeated array into a class constant:
public const array PAYMENT_FAILED_TRANSITION = [
'target' => 'payment.failed',
];Now instead of four identical arrays, you have four references to the same constant. The code looks cleaner. The IDE stops complaining.
But the duplication isn't gone. It just got a name.
The real question isn't “how do I avoid repeating this array?” - it's “why does this transition appear in four places at all?”
What Event Bubbling Actually Does
In a hierarchical state machine, states are nested. A payment compound state contains child states like selecting_method, processing, and awaiting_confirmation. When an event arrives at a child state and that child has no handler for it, the event doesn't just disappear. It travels upward - first to the parent, then to the grandparent, all the way to the root.
This is event bubbling. The same mechanism you know from the DOM, applied to state machines.
XState has it. EventMachine has it too. And if you're defining the same transition in every child state, you're probably not using it.
Before and After
Here's a payment flow inside an e-commerce order machine. The payment compound state has several children, and any of them might encounter a PaymentFailedEvent:
Before (repeated transitions):
'payment' => [
'initial' => 'selecting_method',
'states' => [
'selecting_method' => [
'on' => [
SelectMethodEvent::class => ['target' => 'payment.processing'],
PaymentFailedEvent::class => self::PAYMENT_FAILED_TRANSITION, // 1
],
],
'processing' => [
'entry' => ['chargePaymentAction'],
'on' => [
PaymentConfirmedEvent::class => ['target' => 'payment.confirmed'],
PaymentFailedEvent::class => self::PAYMENT_FAILED_TRANSITION, // 2
],
],
'awaiting_confirmation' => [
'on' => [
PaymentConfirmedEvent::class => ['target' => 'payment.confirmed'],
PaymentFailedEvent::class => self::PAYMENT_FAILED_TRANSITION, // 3
],
],
'retry' => [
'entry' => ['retryPaymentAction'],
'on' => [
PaymentConfirmedEvent::class => ['target' => 'payment.confirmed'],
PaymentFailedEvent::class => self::PAYMENT_FAILED_TRANSITION, // 4
],
],
'confirmed' => ['type' => 'final'],
'failed' => ['type' => 'final'],
],
],Four states, four identical PaymentFailedEvent handlers. The constant hides the repetition but doesn't eliminate it. Every new child state needs to remember to include it. And when someone forgets - good luck tracing why PaymentFailedEvent throws a “no transition found” exception from that one state.
After (event bubbling):
'payment' => [
'initial' => 'selecting_method',
'on' => [
PaymentFailedEvent::class => ['target' => 'payment.failed'], // once
],
'states' => [
'selecting_method' => [
'on' => [
SelectMethodEvent::class => ['target' => 'payment.processing'],
],
],
'processing' => [
'entry' => ['chargePaymentAction'],
'on' => [
PaymentConfirmedEvent::class => ['target' => 'payment.confirmed'],
],
],
'awaiting_confirmation' => [
'on' => [
PaymentConfirmedEvent::class => ['target' => 'payment.confirmed'],
],
],
'retry' => [
'entry' => ['retryPaymentAction'],
'on' => [
PaymentConfirmedEvent::class => ['target' => 'payment.confirmed'],
],
],
'confirmed' => ['type' => 'final'],
'failed' => ['type' => 'final'],
],
],One handler at the parent level. Every child state inherits it automatically. The constant is gone. New child states get failure handling for free.
How It Works Under the Hood
When EventMachine receives an event, it calls findTransitionDefinition(). The algorithm is straightforward:
Check the current (active) state for a matching handler
If none found and we're not at the root, move to the parent and repeat
If we reach the root with no handler, throw an exception
Event: PaymentFailedEvent
Active state: payment.processing
Step 1: processing has no PaymentFailedEvent handler → bubble up
Step 2: payment has PaymentFailedEvent handler → match! → transition to payment.failedThe child states don't need to know about PaymentFailedEvent at all. The parent catches what the children don't handle.
The Forbidden Transition
There's a catch.
If the parent handles PaymentFailedEvent, that handler is now reachable from every child. Including states where it doesn't make sense.
In the example above, confirmed is a final state, so it won't process events anyway. No problem there. But what if you add a refunding state inside payment? A refund in progress shouldn't respond to PaymentFailedEvent with a transition to failed - that would interrupt the refund and leave money in limbo.
The fix: a null target.
'refunding' => [
'entry' => ['processRefundAction'],
'on' => [
RefundCompletedEvent::class => ['target' => 'payment.refunded'],
PaymentFailedEvent::class => null, // block bubbling here
],
],Setting the handler to null tells the machine: “I know about this event. Do nothing.” The event won't bubble to the parent. It's a conscious decision to ignore it, not an oversight.
This works because child handlers always take priority over parent handlers. If a child defines a handler for an event - even null - the parent's version is never consulted. That's the difference between null and simply not defining the event: null blocks it, omitting it lets it bubble.
In XState terminology, this is called a “forbidden transition.” The name fits.
When to Bubble and When Not To
Event bubbling works best when a transition is truly universal across children - error handling, cancellation, timeout. The kind of thing where you'd say “from any state inside X, this event means the same thing.”
Don't bubble when:
Different children need different targets for the same event. If
processingshould go toretrybutawaiting_confirmationshould go tofailed, these aren't the same transition. Keep them at the child level.Only a few children handle the event. If two out of eight states handle
RetryEvent, putting it on the parent makes it available to six states that shouldn't see it. That's not simplification, that's a new bug surface.The transition carries child-specific guards or actions. If
processingneeds ahasRetriesLeftGuardbutawaiting_confirmationdoesn't, these are different transitions that happen to share a target. Keep them separate.
Multiple Levels
Bubbling doesn't stop at one level. It works through the entire state hierarchy.
A machine with three nesting levels:
order (root)
└── fulfillment (compound)
└── shipping (compound)
└── in_transit (active state)If in_transit receives a CancelOrderEvent that it doesn't handle:
in_transit- no handler → bubbleshipping- no handler → bubblefulfillment- no handler → bubbleorder(root) - hasCancelOrderEvent → cancelled→ match!
One handler at the root, available from any state in the entire machine. If you've used root-level on for global transitions like expired or terminated - you've already been using event bubbling. The only new idea is applying it to intermediate compound states.
MachineDefinition::define(
config: [
'id' => 'order',
'initial' => 'placed',
// Root-level bubbling - available from every state
'on' => [
CancelOrderEvent::class => 'cancelled',
OrderExpiredEvent::class => 'expired',
],
'states' => [
'placed' => [/* ... */],
'fulfillment' => [
// Mid-level bubbling - available from every state inside fulfillment
'on' => [
ShippingFailedEvent::class => ['target' => 'fulfillment.failed'],
],
'states' => [
'picking' => [/* ... */],
'packing' => [/* ... */],
'shipping' => [/* ... */],
'failed' => ['type' => 'final'],
],
],
'cancelled' => ['type' => 'final'],
'expired' => ['type' => 'final'],
],
],
);Two levels of bubbling, zero constants, zero repetition.
Refactoring Existing Machines
If you already have a machine with repeated transitions hidden behind constants, here's the playbook:
Step 1: Find the Pattern
Look for class constants that define transitions, or the same EventClass::class => [...] appearing across sibling states.
Step 2: Identify the Common Ancestor
The transition should live on the nearest compound state that contains all the states using it. Don't push it higher than necessary - a PaymentFailedEvent handler belongs on the payment state, not on the root.
Step 3: Check for Exceptions
Before moving the transition to the parent, verify that every child state should handle this event the same way. For any child that shouldn't, add a forbidden transition (null target).
Step 4: Remove and Test
Move the transition to the parent. Remove it from children. Delete the constant. Run your tests. The behavior should be identical.
The Mental Model
Think of your state hierarchy as a set of nested scopes. Each scope can define handlers. When an event arrives, the machine looks for a handler in the current scope first, then moves outward.
Leaf states handle what's specific to them. Compound states handle what's common to their children. The root handles what's global.
The hierarchy itself becomes the DRY mechanism. No constants needed.
Root: CancelOrderEvent, OrderExpiredEvent (global)
Payment: PaymentFailedEvent (all payment states)
Processing: PaymentConfirmedEvent (specific to this state)
Retry: PaymentConfirmedEvent, RetryLimitEvent (specific to this state)Each level handles exactly what it should. Nothing more, nothing less. And the next developer who adds a state inside payment gets failure handling without even knowing it exists.
That's not magic. That's just good hierarchy design.