Skip to content

@always Transitions

@always transitions (also called eventless or transient transitions) execute immediately after entering a state, without waiting for an event. They're useful for conditional routing and state normalization.

Basic Syntax

php
'states' => [
    'checking' => [
        'on' => [
            '@always' => 'nextState',
        ],
    ],
    'nextState' => [],
],

When the machine enters checking, it immediately transitions to nextState.

Infinite Loop Risk

@always transitions can create infinite loops if two states always transition to each other. Always ensure at least one path leads to a state without @always, or use guards that will eventually fail. See Avoiding Infinite Loops for details.

Guarded @always Transitions

Use guards to conditionally route:

php
'states' => [
    'checking' => [
        'on' => [
            '@always' => [
                ['target' => 'approved', 'guards' => 'isApproved'],
                ['target' => 'rejected', 'guards' => 'isRejected'],
                ['target' => 'review'],  // Fallback
            ],
        ],
    ],
    'approved' => [],
    'rejected' => [],
    'review' => [],
],

Execution Order

  1. Enter target state
  2. Execute entry actions
  3. Check for @always transitions
  4. If found, trigger transition immediately

Use Cases

Conditional Routing

Route based on context without requiring an event:

php
'states' => [
    'processing' => [
        'entry' => 'processOrder',
        'on' => [
            '@always' => [
                ['target' => 'express', 'guards' => 'isExpressShipping'],
                ['target' => 'standard'],
            ],
        ],
    ],
    'express' => [...],
    'standard' => [...],
],

Validation Routing

php
'states' => [
    'validating' => [
        'entry' => 'runValidation',
        'on' => [
            '@always' => [
                ['target' => 'valid', 'guards' => 'isValid'],
                ['target' => 'invalid'],
            ],
        ],
    ],
],

Breaking Out of Nested States

php
'review' => [
    'states' => [
        'pending' => [
            'on' => ['APPROVE' => 'approved'],
        ],
        'approved' => [
            'on' => [
                '@always' => '#processing',  // Jump to root-level state
            ],
        ],
    ],
],
'processing' => [...],

State Normalization

Ensure consistent state entry:

php
'states' => [
    'init' => [
        'entry' => 'loadConfiguration',
        'on' => [
            '@always' => 'ready',
        ],
    ],
    'ready' => [...],
],

Computed Transitions

php
'states' => [
    'scoring' => [
        'entry' => 'calculateScore',
        'on' => [
            '@always' => [
                ['target' => 'excellent', 'guards' => 'scoreAbove90'],
                ['target' => 'good', 'guards' => 'scoreAbove70'],
                ['target' => 'passing', 'guards' => 'scoreAbove50'],
                ['target' => 'failing'],
            ],
        ],
    ],
],

With Actions

php
'states' => [
    'checking' => [
        'on' => [
            '@always' => [
                [
                    'target' => 'approved',
                    'guards' => 'isAutoApprovable',
                    'actions' => 'logAutoApproval',
                ],
                [
                    'target' => 'review',
                    'actions' => 'notifyReviewer',
                ],
            ],
        ],
    ],
],

With Calculators

php
'states' => [
    'evaluating' => [
        'on' => [
            '@always' => [
                [
                    'target' => 'approved',
                    'calculators' => 'calculateRiskScore',
                    'guards' => 'isLowRisk',
                ],
                ['target' => 'manualReview'],
            ],
        ],
    ],
],

Practical Examples

Order Routing

php

use Tarfinlabs\EventMachine\Definition\MachineDefinition; 
MachineDefinition::define(
    config: [
        'id' => 'order',
        'initial' => 'received',
        'context' => [
            'items' => [],
            'total' => 0,
            'membershipLevel' => 'standard',
        ],
        'states' => [
            'received' => [
                'entry' => 'calculateTotal',
                'on' => [
                    '@always' => [
                        [
                            'target' => 'vipProcessing',
                            'guards' => 'isVipMember',
                        ],
                        [
                            'target' => 'priorityProcessing',
                            'guards' => 'isLargeOrder',
                        ],
                        ['target' => 'standardProcessing'],
                    ],
                ],
            ],
            'vipProcessing' => [
                'entry' => 'assignVipHandler',
            ],
            'priorityProcessing' => [
                'entry' => 'assignPriorityHandler',
            ],
            'standardProcessing' => [],
        ],
    ],
    behavior: [
        'guards' => [
            'isVipMember' => fn($ctx) => $ctx->membershipLevel === 'vip',
            'isLargeOrder' => fn($ctx) => $ctx->total > 1000,
        ],
    ],
);

Approval Workflow

php
'states' => [
    'submitted' => [
        'entry' => ['validateSubmission', 'checkEligibility'],
        'on' => [
            '@always' => [
                [
                    'target' => 'autoApproved',
                    'guards' => ['isUnderAutoApprovalLimit', 'hasNoRiskFlags'],
                    'actions' => 'logAutoApproval',
                ],
                [
                    'target' => 'pendingFirstApproval',
                    'guards' => 'requiresSingleApproval',
                ],
                [
                    'target' => 'pendingDualApproval',
                ],
            ],
        ],
    ],
    'autoApproved' => [
        'on' => ['@always' => '#processing'],
    ],
    'pendingFirstApproval' => [...],
    'pendingDualApproval' => [...],
],

Quiz Scoring

php
'states' => [
    'calculating' => [
        'entry' => 'computeFinalScore',
        'on' => [
            '@always' => [
                ['target' => 'passed.withHonors', 'guards' => 'scoreAbove95'],
                ['target' => 'passed.standard', 'guards' => 'scoreAbove70'],
                ['target' => 'failed.canRetry', 'guards' => 'hasRetriesLeft'],
                ['target' => 'failed.final'],
            ],
        ],
    ],
],

Cross-Region Synchronization in Parallel States

@always transitions can be used to synchronize regions in parallel states. A region can wait for a sibling region to reach a certain state using a guard that checks the sibling's state:

php
use Tarfinlabs\EventMachine\Actor\State;
use Tarfinlabs\EventMachine\ContextManager;
use Tarfinlabs\EventMachine\Behavior\EventBehavior;
use Tarfinlabs\EventMachine\Definition\MachineDefinition;

MachineDefinition::define(
    config: [
        'id' => 'workflow',
        'initial' => 'processing',
        'states' => [
            'processing' => [
                'type' => 'parallel',
                'onDone' => 'completed',
                'states' => [
                    'dealer' => [
                        'initial' => 'pricing',
                        'states' => [
                            'pricing' => [
                                'on' => ['PRICING_DONE' => 'awaitingApproval'],
                            ],
                            'awaitingApproval' => [
                                'on' => [
                                    // Region waits for sibling to pass policy check
                                    '@always' => [
                                        ['target' => 'paymentOptions', 'guards' => 'isApprovalPassed'],
                                    ],
                                ],
                            ],
                            'paymentOptions' => [
                                'on' => ['PAYMENT_DONE' => 'dealerDone'],
                            ],
                            'dealerDone' => ['type' => 'final'],
                        ],
                    ],
                    'customer' => [
                        'initial' => 'consent',
                        'states' => [
                            'consent' => [
                                'on' => ['CONSENT_GIVEN' => 'approved'],
                            ],
                            'approved' => [
                                'on' => ['SUBMITTED' => 'customerDone'],
                            ],
                            'customerDone' => ['type' => 'final'],
                        ],
                    ],
                ],
            ],
            'completed' => ['type' => 'final'],
        ],
    ],
    behavior: [
        'guards' => [
            'isApprovalPassed' => fn (ContextManager $ctx, EventBehavior $event, State $state)
                => $state->matches('processing.customer.approved')
                || $state->matches('processing.customer.customerDone'),
        ],
    ]
);

How It Works

  1. When a region transitions, @always guards in all active regions are re-evaluated
  2. If the guard passes, the waiting region transitions automatically
  3. If the guard fails, the region stays in its current state (no exception thrown)

This follows the SCXML specification: "By using in guards it is possible to coordinate the different regions."

Alternative: Context Flags

Instead of checking sibling state, you can use context flags:

php
'guards' => [
    'isApproved' => fn (ContextManager $ctx) => $ctx->get('approved') === true,
],
'actions' => [
    'setApproved' => fn (ContextManager $ctx) => $ctx->set('approved', true),
],

Both approaches work. State checking is more declarative; context flags are simpler.

Infinite Loop Protection

EventMachine includes built-in protection against infinite loops caused by @always transitions or raised events. If the recursive transition depth within a single macrostep exceeds 100, a MaxTransitionDepthExceededException is thrown.

What is a macrostep?

A macrostep is everything that happens from a single external transition() call (or getInitialState()) until the machine settles. This includes @always chains and raised event processing — all handled within one call stack. Normal event-driven transitions (where each event is sent externally) are separate macrosteps and are not affected by this limit.

How It Works

php
// This will throw MaxTransitionDepthExceededException
'stateA' => [
    'on' => ['@always' => 'stateB'],
],
'stateB' => [
    'on' => ['@always' => 'stateA'],  // Infinite loop!
],
php
use Tarfinlabs\EventMachine\Definition\MachineDefinition;
use Tarfinlabs\EventMachine\Exceptions\MaxTransitionDepthExceededException;

// The exception provides a clear message with the state route
try {
    $definition = MachineDefinition::define(
        config: [
            'id'      => 'example',
            'initial' => 'a',
            'states'  => [
                'a' => ['on' => ['@always' => 'b']],
                'b' => ['on' => ['@always' => 'a']],
            ],
        ],
    );
    $definition->getInitialState();
} catch (MaxTransitionDepthExceededException $e) {
    assert(str_contains($e->getMessage(), 'Maximum transition depth of 100 exceeded'));
}

Safe Patterns

TIP

Always ensure at least one branch leads to a state without @always, or use guards that will eventually fail.

php
// Safe - guards prevent infinite loop
'retry' => [
    'entry' => 'incrementAttempts',
    'on' => [
        '@always' => [
            ['target' => 'processing', 'guards' => 'canRetry'],
            ['target' => 'failed'],  // Exit when can't retry
        ],
    ],
],
php
// Safe - linear chain (no cycle)
'a' => ['on' => ['@always' => 'b']],
'b' => ['on' => ['@always' => 'c']],
'c' => [],  // Terminal state

What Triggers the Limit

ScenarioProtected?
@always transitions cycling (A → B → A)Yes
raise() event loops (action raises event that leads back)Yes
Mixed @always + raise() loopsYes
Normal event-driven cycles (external transition() calls)No (each is a separate macrostep)
Technical Background

This protection is inspired by IBM Rhapsody's DEFAULT_MAX_NULL_STEPS (default: 100 for C++/C), the industry-standard approach from David Harel's own statechart implementation. The W3C SCXML specification leaves loop prevention to implementations, and the UML spec relies on the designer to ensure termination.

Testing @always Transitions

php

use Tarfinlabs\EventMachine\Definition\MachineDefinition; it('automatically routes based on condition', function () {
    $machine = MachineDefinition::define(
        config: [
            'initial' => 'checking',
            'context' => ['score' => 85],
            'states' => [
                'checking' => [
                    'on' => [
                        '@always' => [
                            ['target' => 'passed', 'guards' => 'isPassing'],
                            ['target' => 'failed'],
                        ],
                    ],
                ],
                'passed' => [],
                'failed' => [],
            ],
        ],
        behavior: [
            'guards' => [
                'isPassing' => fn($ctx) => $ctx->score >= 70,
            ],
        ],
    );

    $state = $machine->getInitialState();

    // Automatically transitioned to 'passed'
    expect($state->matches('passed'))->toBeTrue();
});

Best Practices

1. Always Include a Fallback

php
'@always' => [
    ['target' => 'a', 'guards' => 'guardA'],
    ['target' => 'b', 'guards' => 'guardB'],
    ['target' => 'default'],  // Always have a fallback
],

2. Use for Routing, Not Logic

php
// Good - routing based on existing data
'@always' => [
    ['target' => 'express', 'guards' => 'isExpress'],
    ['target' => 'standard'],
],

// Avoid - complex logic in @always
// Use entry actions + explicit events instead

3. Keep Guards Simple

php
// Good - simple condition
'guards' => fn($ctx) => $ctx->total > 1000,

// Avoid - complex logic
'guards' => fn($ctx) => $this->complexCalculation($ctx) && $this->anotherCheck($ctx),

4. Document the Routing Logic

php
'checking' => [
    'description' => 'Routes orders based on value and membership',
    'on' => [
        '@always' => [
            [
                'target' => 'vip',
                'guards' => 'isVip',
                'description' => 'VIP members get priority',
            ],
            ['target' => 'standard'],
        ],
    ],
],

Released under the MIT License.