Event Handling in Parallel States
How events, actions, and transitions work within parallel state regions.
Related pages:
- Parallel States Overview - Basic concepts and syntax
- Persistence - Database storage and restoration
- Parallel Dispatch - Concurrent execution via queue jobs
Event Handling
Events are broadcast to all active regions. Each region independently evaluates whether it can handle the event.
Single Region Handling
When an event is only defined in one region, only that region transitions:
$state = $definition->getInitialState();
// document: editing, format: normal
$state = $definition->transition(['type' => 'BOLD'], $state);
// document: editing (unchanged)
// format: bold (transitioned)
··· 2 hidden lines
Multiple Region Handling
The same event can trigger transitions in multiple regions simultaneously:
··· 1 hidden line
MachineDefinition::define([
'id' => 'editor',
'initial' => 'active',
'context' => ['value' => ''],
'states' => [
'active' => [
'type' => 'parallel',
'states' => [
'editing' => [
'initial' => 'idle',
'states' => [
'idle' => [
'on' => [
'CHANGE' => [
'target' => 'modified',
'actions' => 'updateValueAction',
],
],
],
'modified' => [],
],
],
'status' => [
'initial' => 'saved',
'states' => [
'saved' => [
'on' => ['CHANGE' => 'unsaved'],
],
'unsaved' => [
'on' => ['SAVE' => 'saved'],
],
],
],
],
],
],
]);
// CHANGE event triggers transitions in BOTH regions
$state = $definition->transition(['type' => 'CHANGE'], $state);
$state->matches('active.editing.modified'); // true
$state->matches('active.status.unsaved'); // trueEntry and Exit Actions
Entry and exit actions fire for each region during transitions. Understanding the execution order is important for proper state initialization and cleanup.
Entry Action Execution Order
When entering a parallel state, entry actions fire in this specific order:
- Parallel state entry - The parallel state's own entry action
- Region 1 initial state entry - First region's initial state
- Region 2 initial state entry - Second region's initial state
- (continues for all regions in definition order)
··· 1 hidden line
MachineDefinition::define(
config: [
'id' => 'machine',
'initial' => 'active',
'states' => [
'active' => [
'type' => 'parallel',
'entry' => 'logParallelEntryAction', // 1. Fires first
'states' => [
'region1' => [
'initial' => 'a',
'states' => [
'a' => [
'entry' => 'logRegion1EntryAction', // 2. Fires second
],
],
],
'region2' => [
'initial' => 'b',
'states' => [
'b' => [
'entry' => 'logRegion2EntryAction', // 3. Fires third
],
],
],
'region3' => [
'initial' => 'c',
'states' => [
'c' => [
'entry' => 'logRegion3EntryAction', // 4. Fires fourth
],
],
],
],
],
],
],
behavior: [
'actions' => [
'logParallelEntryAction' => fn () => Log::info('1. Entering parallel state'),
'logRegion1EntryAction' => fn () => Log::info('2. Entering region 1'),
'logRegion2EntryAction' => fn () => Log::info('3. Entering region 2'),
'logRegion3EntryAction' => fn () => Log::info('4. Entering region 3'),
],
]
);
// Log output:
// 1. Entering parallel state
// 2. Entering region 1
// 3. Entering region 2
// 4. Entering region 3Exit Action Execution Order
Exit actions fire for leaf states and the parallel state itself:
- Leaf state exits - Exit actions for each active leaf state (in definition order)
- Parallel state exit - The parallel state's own exit action (last)
Region Exit Actions
Region (compound state) exit actions are not automatically invoked when leaving a parallel state. Only leaf states and the parallel state itself run exit actions.
··· 1 hidden line
MachineDefinition::define(
config: [
'id' => 'machine',
'initial' => 'active',
'states' => [
'active' => [
'type' => 'parallel',
'exit' => 'logParallelExitAction', // 3. Fires last
'states' => [
'region1' => [
'initial' => 'a',
'states' => [
'a' => [
'exit' => 'logStateAExitAction', // 1. Fires first
],
],
],
'region2' => [
'initial' => 'b',
'states' => [
'b' => [
'exit' => 'logStateBExitAction', // 2. Fires second
],
],
],
],
],
'inactive' => [],
],
],
behavior: [
'actions' => [
'logStateAExitAction' => fn () => Log::info('1. Exiting state a'),
'logStateBExitAction' => fn () => Log::info('2. Exiting state b'),
'logParallelExitAction' => fn () => Log::info('3. Exiting parallel state'),
],
]
);
// When transitioning from 'active' to 'inactive', log output:
// 1. Exiting state a
// 2. Exiting state b
// 3. Exiting parallel stateAction Order Summary
Entry: Outside → Inside (parallel → leaf states in each region) Exit: Leaf states first → Parallel state last
Shared Context
All regions share the same ContextManager. Actions in any region can read and modify the context:
··· 2 hidden lines
MachineDefinition::define(
config: [
'id' => 'counter',
'initial' => 'active',
'context' => ['count' => 0],
'states' => [
'active' => [
'type' => 'parallel',
'states' => [
'incrementer' => [
'initial' => 'ready',
'states' => [
'ready' => [
'on' => [
'INCREMENT' => [
'actions' => 'incrementAction',
],
],
],
],
],
'decrementer' => [
'initial' => 'ready',
'states' => [
'ready' => [
'on' => [
'DECREMENT' => [
'actions' => 'decrementAction',
],
],
],
],
],
],
],
],
],
behavior: [
'actions' => [
'incrementAction' => fn (ContextManager $ctx) => $ctx->set('count', $ctx->get('count') + 1),
'decrementAction' => fn (ContextManager $ctx) => $ctx->set('count', $ctx->get('count') - 1),
],
]
);Context Conflicts
When multiple regions modify the same context key in response to the same event, the last region (in definition order) wins. With Parallel Dispatch enabled, a PARALLEL_CONTEXT_CONFLICT internal event is recorded when this happens, making the overwrite observable in machine history. Design your context structure to use separate keys per region to avoid conflicts entirely.
Final States and @done
When all regions of a parallel state reach their final states, the parallel state is considered complete. Use @done to transition when this happens:
··· 2 hidden lines
MachineDefinition::define([
'id' => 'checkout',
'initial' => 'processing',
'states' => [
'processing' => [
'type' => 'parallel',
'@done' => 'complete', // Transition when ALL regions are final
'states' => [
'payment' => [
'initial' => 'pending',
'states' => [
'pending' => [
'on' => ['PAYMENT_SUCCEEDED' => 'done'],
],
'done' => ['type' => 'final'],
],
],
'shipping' => [
'initial' => 'preparing',
'states' => [
'preparing' => [
'on' => ['SHIPPED' => 'done'],
],
'done' => ['type' => 'final'],
],
],
],
],
'complete' => ['type' => 'final'],
],
]);
$state = $definition->getInitialState();
// processing.payment.pending, processing.shipping.preparing
$state = $definition->transition(['type' => 'PAYMENT_SUCCEEDED'], $state);
// processing.payment.done, processing.shipping.preparing
// Still in processing - shipping not complete
$state = $definition->transition(['type' => 'SHIPPED'], $state);
// Now both regions are final - automatically transitions to 'complete'
$state->matches('complete'); // => true@done with Actions
You can also specify actions to run when the parallel state completes:
'processing' => [
'type' => 'parallel',
'@done' => [
'target' => 'complete',
'actions' => 'sendConfirmationAction',
],
'states' => [...],
],Conditional @done with Guards
Instead of a single target, @done can be an array of branches — each with a target, optional guards, and optional actions. The first branch whose guard passes wins. A branch without a guard acts as the default fallback:
'processing' => [
'type' => 'parallel',
'@done' => [
['target' => 'approved', 'guards' => IsAllSucceededGuard::class, 'actions' => LogApprovalAction::class],
['target' => 'manual_review', 'actions' => NotifyReviewerAction::class], // fallback (no guard)
],
'states' => [
'inventory' => [...],
'payment' => [...],
],
],
'approved' => ['type' => 'final'],
'manual_review' => ['type' => 'final'],Evaluation rules:
- Branches are evaluated top-to-bottom — the first passing guard wins
- Only the winning branch's actions run; losing branch actions are skipped
- If all guards fail and no guardless fallback exists, the machine stays in the parallel state
- Guards receive the current
StateandContextManager, so they can inspect region results
Compound States Too
Conditional @done also works on compound (non-parallel) states. When a compound state's child reaches a final state, the same guard evaluation applies.
@fail — Error Handling
When using Parallel Dispatch, region entry actions run as queue jobs. If a job exhausts all retries, you can handle the failure with @fail:
'processing' => [
'type' => 'parallel',
'@done' => 'completed',
'@fail' => 'failed', // Transition here when a region job fails
'states' => [
'inventory' => [...],
'payment' => [...],
],
],
'failed' => ['type' => 'final'],When @fail is triggered:
- The machine exits the parallel state
- Sibling jobs that haven't started will no-op
- Context from completed siblings is preserved
- A
PARALLEL_FAILinternal event is recorded in history
Without @fail, the machine stays in the parallel state and records the failure event for debugging.
Conditional @fail with Guards
Like @done, @fail supports conditional branches with guards. This enables retry-or-escalate patterns:
'processing' => [
'type' => 'parallel',
'@done' => 'completed',
'@fail' => [
['target' => 'retrying', 'guards' => CanRetryGuard::class, 'actions' => IncrementRetryAction::class],
['target' => 'failed', 'actions' => SendAlertAction::class], // fallback
],
'states' => [...],
],
'retrying' => ['type' => 'final'],
'failed' => ['type' => 'final'],@fail action timing: Branch actions run before exit actions. This allows actions to inspect the parallel state's context (e.g., error details) before the machine transitions out.
Nested Parallel States
Parallel states can be nested within compound states, and compound states can be nested within parallel regions. You can even nest parallel states within parallel states.
Parallel Inside Compound
'active' => [
'initial' => 'loading',
'states' => [
'loading' => [
'on' => ['LOADED' => 'ready'],
],
'ready' => [
'type' => 'parallel', // Parallel state inside compound
'states' => [
'audio' => [...],
'video' => [...],
],
],
],
],Compound Inside Parallel Region
'player' => [
'type' => 'parallel',
'states' => [
'track' => [
'initial' => 'stopped', // Compound inside parallel region
'states' => [
'stopped' => [...],
'playing' => [...],
'paused' => [...],
],
],
'volume' => [
'initial' => 'unmuted',
'states' => [
'unmuted' => [...],
'muted' => [...],
],
],
],
],Deep Nesting (3+ Levels)
You can create complex hierarchies with multiple levels of nesting. EventMachine recursively resolves all leaf states regardless of nesting depth.
Structure: Parallel → Compound → Parallel → Leaf
deep (machine)
└── root (PARALLEL)
├── branch1 (compound)
│ └── leaf (PARALLEL)
│ ├── subleaf1 (compound)
│ │ ├── a ← active leaf
│ │ └── b
│ └── subleaf2 (compound)
│ ├── x ← active leaf
│ └── y
└── branch2 (compound)
├── waiting ← active leaf
└── finished··· 1 hidden line
MachineDefinition::define([
'id' => 'deep',
'initial' => 'root',
'states' => [
'root' => [
'type' => 'parallel', // Level 1: Outer parallel
'states' => [
'branch1' => [
'initial' => 'leaf', // Level 2: Compound region
'states' => [
'leaf' => [
'type' => 'parallel', // Level 3: Nested parallel
'states' => [
'subleaf1' => [
'initial' => 'a', // Level 4: Inner compound
'states' => [
'a' => ['on' => ['GO1' => 'b']], // Level 5: Leaf
'b' => [],
],
],
'subleaf2' => [
'initial' => 'x',
'states' => [
'x' => ['on' => ['GO2' => 'y']],
'y' => [],
],
],
],
],
],
],
'branch2' => [
'initial' => 'waiting',
'states' => [
'waiting' => ['on' => ['DONE' => 'finished']],
'finished' => [],
],
],
],
],
],
]);State Value in Deep Nesting
The $state->value array always contains the fully-qualified IDs of all active leaf states:
$state = $definition->getInitialState();
// State value includes ALL leaf states from ALL nesting levels:
··· 1 hidden line
$state->value;
// [
// 'deep.root.branch1.leaf.subleaf1.a', // From nested parallel, region 1
// 'deep.root.branch1.leaf.subleaf2.x', // From nested parallel, region 2
// 'deep.root.branch2.waiting', // From outer parallel, region 2
// ]
// Note: 3 active states because:
// - Outer parallel (root) has 2 regions: branch1, branch2
// - branch1's initial (leaf) is itself parallel with 2 regions: subleaf1, subleaf2
// - Total: 2 (from nested) + 1 (from outer) = 3 leaf statesTransitions in Deep Nesting
Each region independently handles events at its own level:
$state = $definition->getInitialState();
// branch1.leaf.subleaf1.a, branch1.leaf.subleaf2.x, branch2.waiting
// Event handled by nested parallel region subleaf1
$state = $definition->transition(['type' => 'GO1'], $state);
$state->matches('root.branch1.leaf.subleaf1.b'); // true
$state->matches('root.branch1.leaf.subleaf2.x'); // true
$state->matches('root.branch2.waiting'); // true
// Event handled by nested parallel region subleaf2
$state = $definition->transition(['type' => 'GO2'], $state);
$state->matches('root.branch1.leaf.subleaf1.b'); // true
$state->matches('root.branch1.leaf.subleaf2.y'); // true
$state->matches('root.branch2.waiting'); // true
// Event handled by outer parallel region branch2
$state = $definition->transition(['type' => 'DONE'], $state);
$state->matches('root.branch1.leaf.subleaf1.b'); // => true
$state->matches('root.branch1.leaf.subleaf2.y'); // => true
$state->matches('root.branch2.finished'); // => trueUsing matches() with Deep Nesting
The matches() method checks for exact matches against active leaf states. You must provide the full path from the machine's initial state:
··· 1 hidden line
// Check specific leaf states with matches() - must be full path
$state->matches('root.branch1.leaf.subleaf1.a'); // => true
$state->matches('root.branch1.leaf.subleaf2.x'); // => true
$state->matches('root.branch2.waiting'); // => true
// Intermediate paths do NOT match
$state->matches('root.branch1.leaf'); // => false
$state->matches('root.branch1'); // => false
$state->matches('root'); // => false
// Partial paths (without machine id prefix) also don't work
$state->matches('branch2.waiting'); // => false
$state->matches('subleaf1.a'); // => falseFull Paths Required
Always use the complete path from the initial state to the leaf when calling matches(). For example, use root.branch1.leaf.subleaf1.a instead of just subleaf1.a.
Deep Nesting Best Practices
- Keep nesting to 3 levels or fewer when possible for maintainability
- Use meaningful names that indicate the hierarchy level (e.g.,
region,subregion) - Consider breaking very deep structures into separate machines that communicate via events
Transitioning Into Parallel States
When a transition targets a parallel state, all of its regions are automatically entered.
From Non-Parallel to Parallel
··· 2 hidden lines
MachineDefinition::define([
'id' => 'app',
'initial' => 'idle',
'states' => [
'idle' => [
'on' => ['START' => 'processing'],
],
'processing' => [
'type' => 'parallel',
'states' => [
'task1' => [
'initial' => 'pending',
'states' => [
'pending' => [],
'complete' => [],
],
],
'task2' => [
'initial' => 'pending',
'states' => [
'pending' => [],
'complete' => [],
],
],
],
],
],
]);
$state = $definition->getInitialState();
$state->matches('idle'); // true
$state = $definition->transition(['type' => 'START'], $state);
// Both regions are automatically entered
$state->matches('processing.task1.pending'); // => true
$state->matches('processing.task2.pending'); // => trueTransitioning Into Nested Parallel (Within Parallel Region)
When you're already in a parallel state and a region transitions to a state that is itself parallel, all nested regions are properly initialized:
··· 2 hidden lines
MachineDefinition::define([
'id' => 'nested',
'initial' => 'active',
'states' => [
'active' => [
'type' => 'parallel',
'states' => [
'outer1' => [
'initial' => 'off',
'states' => [
'off' => [
'on' => ['ACTIVATE' => 'on'],
],
'on' => [
'type' => 'parallel', // Target is parallel!
'states' => [
'inner1' => [
'initial' => 'idle',
'states' => [
'idle' => ['on' => ['WORK1' => 'working']],
'working' => [],
],
],
'inner2' => [
'initial' => 'idle',
'states' => [
'idle' => ['on' => ['WORK2' => 'working']],
'working' => [],
],
],
],
],
],
],
'outer2' => [
'initial' => 'waiting',
'states' => [
'waiting' => ['on' => ['PROCEED' => 'done']],
'done' => [],
],
],
],
],
],
]);
$state = $definition->getInitialState();
// Initial: outer1.off, outer2.waiting
$state->value;
// ['nested.active.outer1.off', 'nested.active.outer2.waiting']
// Transition to 'on' which is a parallel state
$state = $definition->transition(['type' => 'ACTIVATE'], $state);
// The nested parallel is fully expanded - both inner regions entered!
$state->value;
// [
// 'nested.active.outer1.on.inner1.idle', // Nested region 1
// 'nested.active.outer1.on.inner2.idle', // Nested region 2
// 'nested.active.outer2.waiting', // Outer region unchanged
// ]
$state->matches('active.outer1.on.inner1.idle'); // => true
$state->matches('active.outer1.on.inner2.idle'); // => true
$state->matches('active.outer2.waiting'); // => trueEntry Actions When Entering Nested Parallel
When transitioning into a nested parallel state, entry actions fire in order:
- The parallel state's entry action (
on) - Each nested region's initial state entry action (
inner1.idle,inner2.idle)
Parallel Dispatch Timing
With Parallel Dispatch enabled, entry actions for each region run as concurrent queue jobs instead of sequentially. This changes the execution model: entry actions no longer share an in-memory context during execution. Each job snapshots context before running, computes a diff after, and merges under a database lock. Regions should write to separate context keys — if two regions write to the same key, a PARALLEL_CONTEXT_CONFLICT event is recorded and the last writer wins. If a region's entry action does not call $this->raise(), a PARALLEL_REGION_STALLED event is recorded as an audit trail.