Skip to content

Defining States ​

This guide shows you how to define states in your machine configuration.

Basic State Definition ​

Every state is defined as a key in the states array:

php

use Tarfinlabs\EventMachine\Definition\MachineDefinition; 
MachineDefinition::define(
    config: [
        'initial' => 'pending',
        'states' => [
            'pending' => [],
            'processing' => [],
            'completed' => [],
        ],
    ],
);

State Properties ​

Each state supports these properties:

PropertyTypeDescription
onarrayEvent-to-transition mappings
entrystring|arrayActions to run when entering
exitstring|arrayActions to run when leaving
typestringState type ('final' for terminal states)
outputstringarray
initialstringInitial child state (for compound states)
statesarrayChild state definitions (for compound states)
metaarrayCustom metadata
descriptionstringHuman-readable description

State Types ​

EventMachine supports three state types:

Atomic States ​

Simple states with no children. Most states are atomic:

php
'pending' => [
    'on' => [
        'SUBMIT' => 'processing',
    ],
],

Compound States ​

States containing nested child states:

php
'active' => [
    'initial' => 'idle',
    'states' => [
        'idle' => [
            'on' => ['START' => 'running'],
        ],
        'running' => [
            'on' => ['PAUSE' => 'paused'],
        ],
        'paused' => [
            'on' => ['RESUME' => 'running'],
        ],
    ],
],

When entering a compound state, the machine automatically enters its initial child state.

Final States ​

Terminal states that end the machine's execution:

php
'completed' => [
    'type' => 'final',
    'output' => 'calculateOutput',  // Optional output behavior
],

Final states cannot have outgoing transitions:

php
// This will throw InvalidStateConfigException
'done' => [
    'type' => 'final',
    'on' => [
        'RESTART' => 'initial',  // Not allowed!
    ],
],

Entry and Exit Actions ​

Execute code when entering or leaving a state:

php
'processing' => [
    'entry' => 'startProcessingAction',           // Single action
    'exit' => ['cleanupAction', 'logCompletionAction'], // Multiple actions
    'on' => [
        'COMPLETE' => 'done',
    ],
],

Actions execute in this order during transitions:

Source State Exit Actions
    ↓
Transition Actions
    ↓
Target State Entry Actions

Machine-Level Entry and Exit ​

Define entry/exit at the root config level for actions that run once during the machine lifecycle:

php
MachineDefinition::define(
    config: [
        'id'      => 'order',
        'initial' => 'pending',
        'entry'   => 'initializeTrackingAction',  // Runs once — when machine starts
        'exit'    => 'finalCleanupAction',         // Runs once — when machine reaches a final state
        'states'  => [
            'pending' => [
                'entry' => 'sendNotificationAction',  // Runs each time 'pending' is entered
                'on'    => ['SUBMIT' => 'completed'],
            ],
            'completed' => ['type' => 'final'],
        ],
    ],
);

Execution order on initialization:

MACHINE_START
  → Root entry actions (once)
    → Initial state entry actions

Execution order when reaching a final state:

Current state exit actions
  → Root exit actions (once)
    → MACHINE_FINISH

Root vs State entry/exit

  • Root entry: Runs once when the machine starts — before any state entry
  • Root exit: Runs once when the machine reaches a final state — after the last state's exit
  • State entry/exit: Runs every time that specific state is entered or left

Root entry does NOT run on every state change. For that, use Listeners.

Listeners ​

Listeners are observers, not behaviors. Actions describe what the machine does on entry/exit/transition — they mutate context, raise events, and run as part of the transition itself. Listeners describe what watches the machine after the transition is recorded — broadcasts, audit logs, analytics, dashboards. Both hook into the same lifecycle, but they answer different questions, and that difference is what makes one async-safe and the other not.

ActionListener
RoleBehavior — part of the transitionObserver — runs after the transition is committed
Mutates contextYes, that's the pointAllowed, but lossy when queued (last-writer-wins)
Sequence-sensitiveYes — later actions read earlier actions' writesNo — each listener is independent
FailureThrows can abort the transitionThrows don't undo the transition (failed jobs land in failed_jobs)
Async-safeNo — context, ordering, and rollback all breakYes — transition is already committed
Typical use"the user balance must reflect this transition""fire a webhook when the order ships"

Why this distinction matters for @queue

@queue exists only on listeners. It is not missing on actions by oversight — it is structurally impossible to make safe there. If entry action #2 in a list of three were dispatched async, action #3 would race with action #2's context writes, the worker would restore the current persisted state (not the dispatch-time state — possibly several transitions later), and any throw on the worker could not abort a transition that has already been recorded. Listeners avoid all three problems because they observe a committed transition, so they don't need rollback, don't need ordering with respect to peers, and see the same state on the worker that the dispatcher saw.

For fire-and-forget Job dispatch from inside an entry action, the idiomatic pattern is a thin wrapper Action that calls dispatch() — see Async Work in Entry Actions.

Listener actions use the same resolution mechanism as all other behaviors — both inline keys and FQCN references work interchangeably (see Behavior Resolution). Instead of adding broadcastAction to 13 states individually, define it once:

php
'listen' => [
    'entry' => BroadcastStateAction::class,  // every non-transient state entry
],
Featureentry / exit (state)entry / exit (root)listen
ScopeOne stateMachine lifecycleEvery state
RunsEach time state is entered/leftOnce on start/completionEach non-transient entry/exit/transition
PurposeState-specific behaviorMachine init/cleanupCross-cutting observers

Listener Types ​

Three listener keys are available:

KeyFires WhenUse Cases
entryAfter entering a non-transient state (after state entry actions)Broadcasting, dashboard, metrics
exitBefore leaving a non-transient state (before state exit actions)Audit logging, time tracking
transitionAfter every successful transition (including targetless)Full audit trail, analytics

entry answers: "which state did the machine enter?" transition answers: "which event was processed?"

Scenarioentryexittransition
Normal (A→B)Target BSource AYes
Targetless (context update)NoNoYes
Self-transition (A→A)A (re-enter)A (leave)Yes
Guard-blockedNoNoNo
Transient (@always)NoNoNo
Init (no @always)Initial stateNoNo

Sync and Queued Actions ​

Use the @queue key in a tuple to dispatch listener actions to the queue. The @ prefix marks it as framework metadata — it never reaches __invoke:

php
'listen' => [
    'entry' => [
        BroadcastAction::class,                                // sync (default)
        [HeavyAuditAction::class, '@queue' => true],          // queued (default queue)
        [AnalyticsAction::class, '@queue' => 'analytics'],    // queued (specific queue)
    ],
],

@queue type: bool|string — true = default queue, 'name' = specific queue, false/omitted = sync.

Listeners also support named parameters alongside @queue:

php
'listen' => [
    'entry' => [
        [AuditAction::class, 'verbose' => true, '@queue' => true],
    ],
],
Use CaseSync or QueuedWhy
broadcast()SyncLaravel already queues broadcast internally
Quick context logSyncFast, no external I/O
External audit APIQueuedSlow, shouldn't block transition
Heavy analyticsQueuedCPU-intensive, offload to worker

How Queued Listeners Work

Queued listeners restore the machine from rootEventId on the worker — same as child delegation and sendTo. The worker sees the machine's latest persisted state. This is EventMachine's standard behavior for all queued operations.

Point-in-Time Data

If you need data from the exact moment of the transition, use a sync listener that dispatches its own job with the captured data.

@queue only works in listen — not in state actions

@queue is a framework-reserved key that is only honored inside listen.entry, listen.exit, and listen.transition. Putting it in a state's entry/exit action list, in transition actions, in guards, or in calculators is a misuse — and since 9.11.0 EventMachine throws InvalidBehaviorDefinitionException at definition time so it cannot fail silently:

php
// ❌ Rejected — @queue is silently dropped here, so it is now an error
'states' => [
    'approved' => [
        'entry' => [
            ApproveAction::class,
            [CreateInstallmentPromissoryNoteAction::class, '@queue' => true], // throws
            SendApprovalNotificationAction::class,
        ],
    ],
],

If you need an entry action to run async, choose one of three options below.

Async Work in Entry Actions ​

State entry actions always run synchronously, in array order, before the machine is persisted. To do work off the request thread you have three options — pick by what your machine has to do with the result:

OptionWhen to useCost
Job actor (state's job key)The state should transition based on the outcome — you need @done / @fail routing.Requires its own state. The framework has to wait somewhere to observe the job's result.
Queued listener (listen.entry + @queue)Work runs after entry but doesn't drive a transition. The worker may write back to context (the listener restores the machine from rootEventId).One layer of indirection (ListenerJob → restore → run). Last-writer-wins on context if multiple listeners queue concurrently.
Wrapper Action (regular action that calls dispatch())True fire-and-forget — no machine state depends on the result, you don't need machine context on the worker.One thin class. The cheapest and most common option.

Wrapper Action — when you don't want a separate state

You don't need to add a state just to fire-and-forget a job. Write a one-line action that dispatches the job and place it in entry like any other action:

php
final class DispatchPromissoryNoteAction extends ActionBehavior
{
    public function __invoke(ContextManager $context): void
    {
        dispatch(new CreateInstallmentPromissoryNoteJob(
            applicationId: $context->applicationId,
        ));
    }
}

'approved' => [
    'entry' => [
        TerminateOtherApplicationsAction::class,
        ApproveAction::class,
        CompleteApplicationAction::class,
        DispatchPromissoryNoteAction::class,  // ← fire-and-forget Job
        SendApprovalNotificationAction::class,
    ],
],

This is also why EventMachine doesn't expose a 'jobs' slot alongside 'actions': a job actor needs its own state because the framework has to wait for @done / @fail; a fire-and-forget job needs nothing — a wrapper Action expresses it more clearly than a new config key would, and stays inside the existing test/fake infrastructure (Machine::fakingAllActions(except: [...]), Bus::fake()).

The same scenario, written as the other two options:

php
// Job actor — promissory note success/failure decides the next state
'approved' => [
    'entry' => [
        TerminateOtherApplicationsAction::class,
        ApproveAction::class,
        CompleteApplicationAction::class,
        SendApprovalNotificationAction::class,
    ],
    'job' => CreateInstallmentPromissoryNoteJob::class,
    'on'  => [
        '@done' => 'note_created',
        '@fail' => 'note_failed',
    ],
],

// Queued listener — note creation runs async on a worker that restores the machine
'approved' => [
    'entry'  => [
        TerminateOtherApplicationsAction::class,
        ApproveAction::class,
        CompleteApplicationAction::class,
        SendApprovalNotificationAction::class,
    ],
    'listen' => [
        'entry' => [[CreateInstallmentPromissoryNoteAction::class, '@queue' => true]],
    ],
],

Execution Order ​

1. Calculators
2. Guards (if fail → no transition, no listeners)
3. listen.exit (source, if non-transient)
4. Source state exit actions
5. Transition actions
6. Target state entry actions
7. listen.entry (target, if non-transient)
8. listen.transition (always — targetless included)
9. @always check → repeat from 1 (listeners skip transient states)

Transient State Skipping ​

States with @always transitions are transient — all listeners skip them automatically:

START → @always routing → @always eligibility → awaiting_consent
                                                  ↑
                                      Listeners fire here (once)

Event Preservation (v8+)

Although listeners skip transient states, entry actions and @always behaviors in those states still receive the original triggering event. See @always Transitions — Event Preservation.

For business-level filtering, add conditions in the action itself.

Listeners and Child Machines ​

Each machine has its own listen config — listeners are not inherited by child machines and do not fire on child machine state changes.

Parent: idle → delegating → completed
                    │
                    ├── Child: step_1 → step_2 → done
                    │   (parent listen does NOT fire here)
                    │
                    └── @done → completed
                        (parent listen.entry fires here)
  • Parent listen.entry fires when the parent enters delegating and when it enters completed (after @done routing) — but NOT when the child transitions between step_1, step_2, and done.
  • Child machines can define their own listen config independently. Child listeners fire during child execution and do not affect parent context.
  • This applies to both sync and async (queued) child delegation.

Internal Events ​

EventWhen
{machine}.listen.entry.start/finishSync entry listeners
{machine}.listen.exit.start/finishSync exit listeners
{machine}.listen.transition.start/finishSync transition listeners
{machine}.listen.queue.{action}.dispatchedQueued listener dispatched
{machine}.listen.queue.{action}.startedWorker picked up job
{machine}.listen.queue.{action}.completedWorker finished

State Metadata ​

Attach custom data to states:

php
'pending_approval' => [
    'meta' => [
        'description' => 'Waiting for manager approval',
        'timeout' => 86400,  // 24 hours
        'notify' => ['manager@example.com'],
    ],
    'on' => [
        'APPROVE' => 'approved',
        'REJECT' => 'rejected',
    ],
],

Access metadata from state:

php
$state->currentStateDefinition->meta['timeout']; // 86400

State Descriptions ​

Add human-readable descriptions:

php
'awaiting_payment' => [
    'description' => 'Order is waiting for customer payment',
    'on' => [
        'PAY' => 'paid',
        'CANCEL' => 'cancelled',
    ],
],

Access via:

php
$state->currentStateDefinition->description;

State Hierarchy and IDs ​

States are identified by their path from the root:

php

use Tarfinlabs\EventMachine\Definition\MachineDefinition; 
MachineDefinition::define(
    config: [
        'id' => 'order',
        'initial' => 'checkout',
        'states' => [
            'checkout' => [
                'initial' => 'cart',
                'states' => [
                    'cart' => [],
                    'shipping' => [],
                    'payment' => [],
                ],
            ],
        ],
    ],
);

State IDs follow the pattern {machine_id}.{path}:

StateFull ID
checkoutorder.checkout
cartorder.checkout.cart
shippingorder.checkout.shipping

You can customize the delimiter:

php
'delimiter' => '/',  // Results in: order/checkout/cart

Complete Example ​

php
use Tarfinlabs\EventMachine\Definition\MachineDefinition;

MachineDefinition::define(
    config: [
        'id' => 'document',
        'initial' => 'draft',
        'states' => [
            'draft' => [
                'description' => 'Document is being edited',
                'entry' => 'initializeDraftAction',
                'on' => [
                    'SUBMIT' => 'review',
                    'DELETE' => 'deleted',
                ],
            ],
            'review' => [
                'description' => 'Document under review',
                'initial' => 'pending',
                'states' => [
                    'pending' => [
                        'entry' => 'notifyReviewersAction',
                        'on' => [
                            'APPROVE' => 'approved',
                            'REJECT' => 'rejected',
                        ],
                    ],
                    'approved' => [
                        'exit' => 'logApprovalAction',
                    ],
                    'rejected' => [
                        'exit' => 'logRejectionAction',
                    ],
                ],
                'on' => [
                    'PUBLISH' => [
                        'target' => 'published',
                        'guards' => 'isApprovedGuard',
                    ],
                    'REVISE' => 'draft',
                ],
            ],
            'published' => [
                'type' => 'final',
                'entry' => 'notifyPublishedAction',
                'output' => 'getPublishedDocumentOutput',
                'meta' => [
                    'public' => true,
                ],
            ],
            'deleted' => [
                'type' => 'final',
            ],
        ],
    ],
    behavior: [
        'actions' => [
            'initializeDraftAction' => InitializeDraftAction::class,
            'notifyReviewersAction' => NotifyReviewersAction::class,
            'notifyPublishedAction' => NotifyPublishedAction::class,
            'logApprovalAction' => LogApprovalAction::class,
            'logRejectionAction' => LogRejectionAction::class,
        ],
        'guards' => [
            'isApprovedGuard' => IsApprovedGuard::class,
        ],
        'outputs' => [
            'getPublishedDocumentOutput' => GetPublishedDocumentOutput::class,
        ],
    ],
);

State Definition Reference ​

php
'stateName' => [
    // Transitions (see Writing Transitions)
    'on' => [
        'EVENT' => 'targetState',
    ],

    // Lifecycle actions
    'entry' => 'actionNameAction',              // or ['action1Action', 'action2Action']
    'exit' => 'actionNameAction',               // or ['action1Action', 'action2Action']

    // State type
    'type' => 'final',                    // Only for terminal states

    // Final state output
    'output' => 'outputBehaviorNameOutput',

    // Hierarchy
    'initial' => 'childStateName',        // Initial child state
    'states' => [                         // Child states
        'childState' => [...],
    ],

    // Metadata
    'meta' => [
        'key' => 'value',
    ],
    'description' => 'Human readable text',
],

Configuration Validation ​

EventMachine validates your machine configuration at definition time via StateConfigValidator. Any structural errors — invalid keys, wrong state types, conflicting options — throw InvalidStateConfigException with a descriptive message pointing to the exact problem.

Common validation errors include:

ErrorCause
Invalid root-level keysTypo in a top-level config key
Invalid state keysUnknown key inside a state definition
Invalid state typetype is not 'final' or 'parallel'
Final state with transitionsFinal state has on key
Final state with childrenFinal state has states key
Parallel state without regionstype: 'parallel' but states is empty

You can also run validation via artisan:

bash
php artisan machine:validate --all

This scans every discoverable Machine class and reports config and wiring errors without running the application. --all (or a machine name) is required: calling it bare is a usage error.

MachineDefinitionNotFoundException

If a Machine subclass does not implement the definition() method, MachineDefinitionNotFoundException is thrown when the machine is instantiated or discovered by artisan commands.

Listener Validation ​

Listener config must use the current array format. The removed class-as-key format (e.g., [MyAction::class => ['queue' => true]]) throws InvalidListenerDefinitionException. Use the tuple format instead:

php
// Correct
'listen' => [
    'entry' => [[MyAction::class, '@queue' => true]],
],

// Rejected — throws InvalidListenerDefinitionException
'listen' => [
    'entry' => [MyAction::class => ['queue' => true]],
],

Testing State Definitions ​

php
OrderMachine::test()
    ->assertState('idle')
    ->send('SUBMIT')
    ->assertState('submitted')
    ->send('PAY')
    ->assertState('paid')
    ->assertFinished();  // verify final state

Full Testing Guide

See Transitions and Paths for more examples.

Released under the MIT License.