@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
'states' => [
'checking' => [
'on' => [
'@always' => 'next_state',
],
],
'next_state' => [],
],When the machine enters checking, it immediately transitions to next_state.
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:
'states' => [
'checking' => [
'on' => [
'@always' => [
['target' => 'approved', 'guards' => 'isApprovedGuard'],
['target' => 'rejected', 'guards' => 'isRejectedGuard'],
['target' => 'review'], // Fallback
],
],
],
'approved' => [],
'rejected' => [],
'review' => [],
],Execution Order
- Enter target state
- Execute entry actions
- Check for
@alwaystransitions - If found, trigger transition immediately
Use Cases
Conditional Routing
Route based on context without requiring an event:
'states' => [
'processing' => [
'entry' => 'processOrderAction',
'on' => [
'@always' => [
['target' => 'express', 'guards' => 'isExpressShippingGuard'],
['target' => 'standard'],
],
],
],
'express' => [...],
'standard' => [...],
],Validation Routing
'states' => [
'validating' => [
'entry' => 'runValidationAction',
'on' => [
'@always' => [
['target' => 'valid', 'guards' => 'isValidGuard'],
['target' => 'invalid'],
],
],
],
],Breaking Out of Nested States
'review' => [
'states' => [
'pending' => [
'on' => ['APPROVE' => 'approved'],
],
'approved' => [
'on' => [
'@always' => '#processing', // Jump to root-level state
],
],
],
],
'processing' => [...],State Normalization
Ensure consistent state entry:
'states' => [
'init' => [
'entry' => 'loadConfigurationAction',
'on' => [
'@always' => 'ready',
],
],
'ready' => [...],
],Computed Transitions
'states' => [
'scoring' => [
'entry' => 'calculateScoreAction',
'on' => [
'@always' => [
['target' => 'excellent', 'guards' => 'scoreAbove90Guard'],
['target' => 'good', 'guards' => 'scoreAbove70Guard'],
['target' => 'passing', 'guards' => 'scoreAbove50Guard'],
['target' => 'failing'],
],
],
],
],With Actions
'states' => [
'checking' => [
'on' => [
'@always' => [
[
'target' => 'approved',
'guards' => 'isAutoApprovableGuard',
'actions' => 'logAutoApprovalAction',
],
[
'target' => 'review',
'actions' => 'notifyReviewerAction',
],
],
],
],
],With Calculators
'states' => [
'evaluating' => [
'on' => [
'@always' => [
[
'target' => 'approved',
'calculators' => 'calculateRiskScoreCalculator',
'guards' => 'isLowRiskGuard',
],
['target' => 'manual_review'],
],
],
],
],Practical Examples
Order Routing
··· 1 hidden line
MachineDefinition::define(
config: [
'id' => 'order',
'initial' => 'received',
'context' => [
'items' => [],
'total' => 0,
'membershipLevel' => 'standard',
],
'states' => [
'received' => [
'entry' => 'calculateTotalAction',
'on' => [
'@always' => [
[
'target' => 'vip_processing',
'guards' => 'isVipMemberGuard',
],
[
'target' => 'priority_processing',
'guards' => 'isLargeOrderGuard',
],
['target' => 'standard_processing'],
],
],
],
'vip_processing' => [
'entry' => 'assignVipHandlerAction',
],
'priority_processing' => [
'entry' => 'assignPriorityHandlerAction',
],
'standard_processing' => [],
],
],
behavior: [
'guards' => [
'isVipMemberGuard' => fn($ctx) => $ctx->membershipLevel === 'vip',
'isLargeOrderGuard' => fn($ctx) => $ctx->total > 1000,
],
],
);Approval Workflow
'states' => [
'submitted' => [
'entry' => ['validateSubmissionAction', 'checkEligibilityAction'],
'on' => [
'@always' => [
[
'target' => 'auto_approved',
'guards' => ['isUnderAutoApprovalLimitGuard', 'hasNoRiskFlagsGuard'],
'actions' => 'logAutoApprovalAction',
],
[
'target' => 'pending_first_approval',
'guards' => 'requiresSingleApprovalGuard',
],
[
'target' => 'pending_dual_approval',
],
],
],
],
'auto_approved' => [
'on' => ['@always' => '#processing'],
],
'pending_first_approval' => [...],
'pending_dual_approval' => [...],
],Quiz Scoring
'states' => [
'calculating' => [
'entry' => 'computeFinalScoreAction',
'on' => [
'@always' => [
['target' => 'passed.withHonors', 'guards' => 'scoreAbove95Guard'],
['target' => 'passed.standard', 'guards' => 'scoreAbove70Guard'],
['target' => 'failed.canRetry', 'guards' => 'hasRetriesLeftGuard'],
['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:
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',
'@done' => 'completed',
'states' => [
'dealer' => [
'initial' => 'pricing',
'states' => [
'pricing' => [
'on' => ['PRICING_DONE' => 'awaiting_approval'],
],
'awaiting_approval' => [
'on' => [
// Region waits for sibling to pass policy check
'@always' => [
['target' => 'payment_options', 'guards' => 'isApprovalPassedGuard'],
],
],
],
'payment_options' => [
'on' => ['PAYMENT_DONE' => 'dealer_done'],
],
'dealer_done' => ['type' => 'final'],
],
],
'customer' => [
'initial' => 'consent',
'states' => [
'consent' => [
'on' => ['CONSENT_GIVEN' => 'approved'],
],
'approved' => [
'on' => ['SUBMITTED' => 'customer_done'],
],
'customer_done' => ['type' => 'final'],
],
],
],
],
'completed' => ['type' => 'final'],
],
],
behavior: [
'guards' => [
'isApprovalPassedGuard' => fn (ContextManager $ctx, EventBehavior $event, State $state)
=> $state->matches('processing.customer.approved')
|| $state->matches('processing.customer.customer_done'),
],
]
);How It Works
- When a region transitions,
@alwaysguards in all active regions are re-evaluated - If the guard passes, the waiting region transitions automatically
- 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:
'guards' => [
'isApprovedGuard' => fn (ContextManager $ctx) => $ctx->get('approved') === true,
],
'actions' => [
'setApprovedAction' => fn (ContextManager $ctx) => $ctx->set('approved', true),
],Both approaches work. State checking is more declarative; context flags are simpler.
Avoiding Infinite Loops
DANGER
Be careful not to create infinite loops:
// DON'T DO THIS - infinite loop!
'state_a' => [
'on' => ['@always' => 'state_b'],
],
'state_b' => [
'on' => ['@always' => 'state_a'],
],TIP
Always ensure at least one branch leads to a state without @always, or use guards that will eventually fail.
// Safe - guards prevent infinite loop
'retry' => [
'entry' => 'incrementAttemptsAction',
'on' => [
'@always' => [
['target' => 'processing', 'guards' => 'canRetryGuard'],
['target' => 'failed'], // Exit when can't retry
],
],
],Testing @always Transitions
··· 1 hidden line
it('automatically routes based on condition', function () {
$machine = MachineDefinition::define(
config: [
'initial' => 'checking',
'context' => ['score' => 85],
'states' => [
'checking' => [
'on' => [
'@always' => [
['target' => 'passed', 'guards' => 'isPassingGuard'],
['target' => 'failed'],
],
],
],
'passed' => [],
'failed' => [],
],
],
behavior: [
'guards' => [
'isPassingGuard' => fn($ctx) => $ctx->score >= 70,
],
],
);
$state = $machine->getInitialState();
// Automatically transitioned to 'passed'
expect($state->matches('passed'))->toBeTrue();
});Best Practices
1. Always Include a Fallback
'@always' => [
['target' => 'a', 'guards' => 'guardAGuard'],
['target' => 'b', 'guards' => 'guardBGuard'],
['target' => 'default'], // Always have a fallback
],2. Use for Routing, Not Logic
// Good - routing based on existing data
'@always' => [
['target' => 'express', 'guards' => 'isExpressGuard'],
['target' => 'standard'],
],
// Avoid - complex logic in @always
// Use entry actions + explicit events instead3. Keep Guards Simple
// 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
'checking' => [
'description' => 'Routes orders based on value and membership',
'on' => [
'@always' => [
[
'target' => 'vip',
'guards' => 'isVipGuard',
'description' => 'VIP members get priority',
],
['target' => 'standard'],
],
],
],