Skip to content

State Design ​

States represent conditions -- what the machine currently is. The most important test for a state name is the "is" test: "The order is ___". If it does not read naturally, it probably should not be a state.

The Decision Rule ​

Does the value change which transitions are available? State. Does it just carry data forward? Context.

This single question prevents 90% of state-design mistakes. If a piece of information affects the set of outgoing transitions, it belongs in the state hierarchy. If it is merely data that actions or guards read, it belongs in context.

Anti-Pattern: State Explosion ​

Three independent boolean dimensions produce eight combinations:

php
// Anti-pattern: 8 states for 3 booleans

'states' => [
    'processing_priority_insured'     => [],
    'processing_priority_uninsured'   => [],
    'processing_standard_insured'     => [],
    'processing_standard_uninsured'   => [],
    'completed_priority_insured'      => [],
    'completed_priority_uninsured'    => [],
    'completed_standard_insured'      => [],
    'completed_standard_uninsured'    => [],
],

Each new boolean doubles the state count. This is unmanageable.

Fix: Use context for the independent dimensions and keep only the lifecycle in states.

php
// Clean: 2 states + context flags

'context' => [
    'is_priority' => false,
    'is_insured'  => false,
],
'states' => [
    'processing' => [
        'on' => [
            'ORDER_COMPLETED' => [
                'target' => 'completed',
                'guards' => 'isInsuredGuard',    // reads context
            ],
        ],
    ],
    'completed' => ['type' => 'final'],
],

If the booleans genuinely create independent lifecycles (different events, different transitions), use parallel states instead.

Anti-Pattern: Flag States ​

Encoding a context flag into the state name:

php
// Anti-pattern: same lifecycle, different flag

'processing_with_priority'    => [
    'on' => ['ORDER_COMPLETED' => 'completed'],
],
'processing_without_priority' => [
    'on' => ['ORDER_COMPLETED' => 'completed'],
],

Both states have identical transitions. The "priority" flag does not change the machine's structure.

Fix: One processing state with is_priority in context. Guards or actions read the flag when it matters.

php
'context' => [
    'is_priority' => false,
],
'states' => [
    'processing' => [
        'on' => [
            'ORDER_COMPLETED' => [
                'target'  => 'completed',
                'actions' => 'notifyCompletionAction',  // action checks is_priority
            ],
        ],
    ],
    'completed' => ['type' => 'final'],
],

Anti-Pattern: Mirrored States ​

Creating parallel copies for different actors:

php
// Anti-pattern: duplicated structure

'approved_by_manager'  => ['on' => ['ORDER_COMPLETED' => 'completed']],
'approved_by_director' => ['on' => ['ORDER_COMPLETED' => 'completed']],
'approved_by_vp'       => ['on' => ['ORDER_COMPLETED' => 'completed']],

Fix: One approved state, store the approver in context.

php
'context' => [
    'approved_by' => null,
],
'states' => [
    'approved' => [
        'on' => ['ORDER_COMPLETED' => 'completed'],
    ],
    'completed' => ['type' => 'final'],
],

Refactoring Recipe: Flat to Hierarchical ​

Consider a 12-state flat machine for an order workflow:

php
// Before: 12 flat states with duplicated CANCEL transitions

'states' => [
    'idle'                 => ['on' => ['ORDER_SUBMITTED' => 'submitted', 'ORDER_CANCELLED' => 'cancelled']],
    'submitted'            => ['on' => ['PAYMENT_RECEIVED' => 'awaiting_shipment', 'ORDER_CANCELLED' => 'cancelled']],
    'awaiting_shipment'    => ['on' => ['SHIPMENT_DISPATCHED' => 'shipped', 'ORDER_CANCELLED' => 'cancelled']],
    'shipped'              => ['on' => ['DELIVERY_CONFIRMED' => 'delivered']],
    'delivered'            => ['on' => ['ORDER_CLOSED' => 'completed']],
    // ... more states, each repeating ORDER_CANCELLED ...
    'cancelled' => ['type' => 'final'],
    'completed' => ['type' => 'final'],
],

Every cancellable state duplicates ORDER_CANCELLED. Refactor into a hierarchical machine:

php
// After: hierarchical, with root-level cancel

'states' => [
    'active' => [
        'initial' => 'idle',
        'states'  => [
            'idle'              => ['on' => ['ORDER_SUBMITTED' => 'submitted']],
            'submitted'         => ['on' => ['PAYMENT_RECEIVED' => 'awaiting_shipment']],
            'awaiting_shipment' => ['on' => ['SHIPMENT_DISPATCHED' => 'shipped']],
            'shipped'           => ['on' => ['DELIVERY_CONFIRMED' => 'delivered']],
            'delivered'         => ['on' => ['ORDER_CLOSED' => '#order_workflow.completed']],
        ],
        'on' => [
            'ORDER_CANCELLED' => '#order_workflow.cancelled',  // one handler for all
        ],
    ],
    'cancelled' => ['type' => 'final'],
    'completed' => ['type' => 'final'],
],

The ORDER_CANCELLED handler is defined once on the active parent. Event bubbling handles the rest.

Error States vs Alarm Actions ​

Don't create a separate state for each possible failure cause. Use a single error state per concern area and differentiate failure reasons through entry actions and context.

Anti-Pattern: State Proliferation for Errors ​

php
// Anti-pattern: one state per error cause
'payment_gateway_timeout'    => ['type' => 'final'],
'payment_insufficient_funds' => ['type' => 'final'],
'payment_card_declined'      => ['type' => 'final'],
'payment_fraud_detected'     => ['type' => 'final'],

Four final states for the same concern (payment failure). Each adds transitions, test cases, and cognitive load without adding control value -- the parent machine rarely routes differently for each cause.

Fix: Single Error State, Reason in Context ​

php
'payment_failed' => [
    'type'  => 'final',
    'entry' => 'recordPaymentFailureAction',  // stores reason in context
],

The entry action records the specific cause (gateway_timeout, insufficient_funds, etc.) in context for logging and debugging. The state itself carries the control signal: "payment failed."

When Separate Error States ARE Appropriate

If the machine is a child and the parent needs to route differently based on failure type, use separate final states with @done.{state} routing. Separate states are for control (the parent needs to branch). Context and actions are for information (logging, notification, debugging).

Guidelines ​

  1. Apply the "is" test. Every state name must complete "The order is ___". Use adjectives (idle, active), past participles (submitted, paid), or present participles (processing, validating).

  2. State changes = transition changes. If adding a new value does not add, remove, or alter any transitions, it belongs in context, not states.

  3. Prefer hierarchy over duplication. Group states that share common transitions under a parent and define shared handlers once.

  4. Limit leaf states to 5-7 per parent. If a parent has more than seven children, look for grouping opportunities.

Released under the MIT License.