Transition Design
Transitions connect states. They define when and how a machine moves from one condition to another. Understanding the different transition types -- and their subtleties -- prevents a class of bugs that are hard to diagnose at runtime.
Self-Transitions vs Targetless Transitions
These two concepts look similar but behave differently.
Self-Transition
A self-transition targets the current state. The machine exits and re-enters the same state, firing exit actions, transition actions, and entry actions.
'awaiting_payment' => [
'entry' => 'sendPaymentReminderAction',
'exit' => 'logPaymentAttemptAction',
'on' => [
'PAYMENT_RETRY_REQUESTED' => [
'target' => 'awaiting_payment', // self-transition: exit + re-enter
'actions' => 'incrementRetryAction',
],
],
],When PAYMENT_RETRY_REQUESTED fires: logPaymentAttemptAction (exit) -> incrementRetryAction (transition) -> sendPaymentReminderAction (entry). The state "restarts".
Targetless Transition
A targetless transition has no target. Actions run, but the state does not change. No exit or entry actions fire.
'awaiting_payment' => [
'entry' => 'sendPaymentReminderAction',
'on' => [
'UPDATE_AMOUNT' => [
'actions' => 'recalculateAmountAction', // runs, but no state change
],
],
],When UPDATE_AMOUNT fires: only recalculateAmountAction runs. The machine stays in awaiting_payment without re-triggering entry or exit.
Decision rule: Need to re-initialize the state? Self-transition. Just update context? Targetless.
@always Chains
@always transitions fire immediately after entering a state. They are powerful for routing but dangerous if misused.
No Need to Copy Event Data (v8+)
Since v8, behaviors on @always transitions receive the original triggering event. You no longer need to copy event payload into context before an @always chain. See @always Transitions — Event Preservation.
The Termination Rule
Every @always chain must eventually reach a state without @always, or use guards that will eventually fail. If it does not, the machine hits the depth limit (100) and throws MaxTransitionDepthExceededException.
// Safe: linear chain terminates
'evaluating' => [
'entry' => 'computeScoreAction',
'on' => [
'@always' => [
['target' => 'approved', 'guards' => 'isScoreHighGuard'],
['target' => 'under_review'], // fallback -- no @always, terminates
],
],
],
'approved' => [],
'under_review' => [],// Dangerous: cycle without exit
'state_a' => [
'on' => ['@always' => 'state_b'],
],
'state_b' => [
'on' => ['@always' => 'state_a'], // infinite loop!
],Always Include a Fallback
Multi-branch @always transitions should end with an unguarded fallback:
'@always' => [
['target' => 'express_processing', 'guards' => 'isExpressGuard'],
['target' => 'prioritized', 'guards' => 'isPriorityGuard'],
['target' => 'standard_processing'], // always reachable
],Without the fallback, if no guard passes, the machine stays in the current state -- which may cause the @always to re-evaluate on the next event, leading to confusion.
Multi-Branch Transitions
When an event has multiple possible targets, guards determine which branch wins. The first matching guard takes the transition.
'awaiting_approval' => [
'on' => [
'APPROVAL_SUBMITTED' => [
['target' => 'auto_approved', 'guards' => 'isUnderAutoLimitGuard'],
['target' => 'awaiting_manager_approval', 'guards' => 'isUnderManagerLimitGuard'],
['target' => 'awaiting_director_approval'], // fallback
],
],
],Anti-Pattern: Relying on Definition Order
// Anti-pattern: implicit fallback depends on ordering
'APPROVAL_SUBMITTED' => [
['target' => 'auto_approved', 'guards' => 'isLowRiskGuard'],
['target' => 'under_manual_review', 'guards' => 'isHighRiskGuard'],
// What if neither guard passes? No transition fires.
],Fix: Always include an explicit unguarded fallback as the last branch, or ensure your guards are exhaustive.
'APPROVAL_SUBMITTED' => [
['target' => 'auto_approved', 'guards' => 'isLowRiskGuard'],
['target' => 'under_manual_review', 'guards' => 'isHighRiskGuard'],
['target' => 'pending_review'], // explicit fallback
],Guard Priority: Errors First
In multi-branch transitions, guard evaluation order matters -- the first passing guard wins. Put error and failure guards before the happy-path fallback. This ensures failures are caught before the default path takes over.
This Rule Applies to Multi-Branch Only
Different event keys in the same on array (PAYMENT_CAPTURED, PAYMENT_FAILED) do not compete. Each event targets a specific key. Guard priority only matters for multi-branch transitions where the same trigger has multiple possible targets.
Anti-Pattern: Happy Path First
// Anti-pattern: unguarded fallback first — guards are never evaluated
'evaluating' => [
'on' => [
'@always' => [
['target' => 'processing'], // matches immediately
['target' => 'retrying', 'guards' => 'canRetryGuard'], // unreachable
['target' => 'failed', 'guards' => 'hasErrorGuard'], // unreachable
],
],
],The unguarded branch matches first every time. The error and retry guards are never evaluated.
Fix: Error guards first, happy-path fallback last:
'evaluating' => [
'on' => [
'@always' => [
['target' => 'failed', 'guards' => 'hasErrorGuard'], // error first
['target' => 'retrying', 'guards' => 'canRetryGuard'], // retry second
['target' => 'processing'], // fallback last
],
],
],Same principle for guarded transitions on a specific event:
'PAYMENT_RESULT' => [
['target' => 'failed', 'guards' => 'isPaymentDeclinedGuard'], // error first
['target' => 'captured'], // fallback last
],Anti-Pattern: @always Without Terminal Path
// Anti-pattern: @always cycle through context mutation
'retrying' => [
'entry' => 'incrementRetryAction',
'on' => [
'@always' => [
['target' => 'processing', 'guards' => 'canRetryGuard'],
// No fallback -- if canRetryGuard returns true forever, infinite loop
],
],
],
'processing' => [
'on' => ['PROCESSING_FAILED' => 'retrying'],
],If canRetryGuard always returns true, the @always chain never terminates within a macrostep. In practice, the depth limit (100) catches this, but it is a design error, not a feature.
Fix: Add a terminal fallback.
'retrying' => [
'entry' => 'incrementRetryAction',
'on' => [
'@always' => [
['target' => 'processing', 'guards' => 'canRetryGuard'],
['target' => 'failed'], // terminal when retries exhausted
],
],
],Example: Approval With Escalation
A complete multi-branch pattern with escalation:
'id' => 'order_workflow',
'initial' => 'submitted',
'context' => [
'order_total' => 0,
'approved_by' => null,
],
'states' => [
'submitted' => [
'on' => [
'@always' => [
[
'target' => 'auto_approved',
'guards' => 'isUnderAutoApprovalLimitGuard',
'actions' => 'logAutoApprovalAction',
],
[
'target' => 'awaiting_manager_approval',
'guards' => 'isUnderManagerLimitGuard',
],
['target' => 'awaiting_director_approval'],
],
],
],
'auto_approved' => ['on' => ['@always' => 'processing']],
'awaiting_manager_approval' => ['on' => ['ORDER_APPROVED' => 'processing']],
'awaiting_director_approval' => ['on' => ['ORDER_APPROVED' => 'processing']],
'processing' => [],
],Orders under the auto-approval threshold skip human review. Mid-range orders go to a manager. Large orders go to a director. The @always chain terminates because every branch leads to a state without @always.
Guidelines
Self-transition to restart, targetless to update. Know which one you need before defining the transition.
Every
@alwayschain must terminate. End with a fallback or a guard that eventually fails. The depth limit is a safety net, not flow control.First guard wins. In multi-branch transitions, order matters. Put the most specific guard first, the broadest last.
Always include a fallback. The last branch in a multi-target transition should have no guard.
Document escalation paths. When transitions branch based on thresholds, a comment explaining the business rule is worth more than the code itself.
Related
- Transitions -- reference documentation
- @always Transitions -- eventless transitions
- Guard Design -- writing pure guards
- Event Bubbling -- how handlers are resolved