Machine Lifecycle
Understanding the complete lifecycle of an EventMachine helps you build correct and predictable state machines.
Overview
┌─────────────────────────────────────────────────────────────┐
│ MACHINE LIFECYCLE │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. CREATE 2. START 3. SEND EVENTS │
│ ───────── ───────── ────────────── │
│ Machine::create() Initial state $machine->send() │
│ Entry actions │
│ │
│ ┌──────────────┐ │
│ │ Transition │ │
│ │ Execution │ │
│ │ (see below) │ │
│ └──────────────┘ │
│ │
│ 4. PERSIST 5. RESTORE 6. FINAL STATE │
│ ────────── ────────── ───────────── │
│ Auto-saved create(state: id) Machine done │
│ to database Output computed │
│ │
└─────────────────────────────────────────────────────────────┘1. Creation
When you create a machine:
$machine = OrderMachine::create();What happens:
- Machine definition is loaded
- Initial context is set up
- Machine is NOT started yet (no entry actions run)
With existing state:
$machine = OrderMachine::create(state: $rootEventId);What happens:
- Events are loaded from database
- State is replayed to rebuild current position
- Context is reconstructed from event history
1b. Input Validation (Delegation Only)
When a machine is created as a child via the machine key with a typed input, the MachineInput is validated before the machine starts:
Parent enters delegation state
→ Resolve input (MachineInput class, closure, or array)
→ If MachineInput class: construct from parent context
→ Validate required parameters
→ If validation fails: MachineInputValidationException → @fail on parent
→ If valid: merge input properties into child context
→ Proceed to child startIn async mode, this validation happens inside ChildMachineJob. A validation failure dispatches ChildMachineCompletionJob with an error, routing @fail on the parent.
2. Start / Initial State
The first time you interact with the machine, it enters the initial state:
$state = $machine->state; // Or $machine->send(...)What happens:
{machine}.startinternal event fires- Root-level entry actions run (if defined) —
{machine}.entry.start/{machine}.entry.finish - Initial state's entry actions run —
{machine}.state.{state}.entry.start/.finish - First event is persisted (if persistence enabled)
'initial' => 'pending',
'entry' => 'initializeTrackingAction', // Root entry — runs once on start
'states' => [
'pending' => [
'entry' => ['logOrderCreated', 'notifyCustomer'],
// These run after root entry
],
]When the machine reaches a final state, root exit actions run before {machine}.finish:
State exit actions
→ {machine}.exit.start
→ Root exit actions
→ {machine}.exit.finish
→ {machine}.finish3. Sending Events
$state = $machine->send(['type' => 'PAY', 'amount' => 99.99]);This triggers the Transition Execution process (detailed below).
4. Transition Execution
When an event is sent, here's exactly what happens:
┌─────────────────────────────────────────────────────────────┐
│ TRANSITION EXECUTION ORDER │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. CALCULATORS │
│ └─► Prepare context values needed by guards │
│ │
│ 2. GUARDS │
│ └─► Check conditions (first matching branch wins) │
│ └─► If all guards fail, transition is blocked │
│ │
│ 3. LISTEN EXIT │
│ └─► Run listen.exit on source (if non-transient) │
│ │
│ 4. EXIT ACTIONS │
│ └─► Run current state's exit actions │
│ │
│ 5. TRANSITION ACTIONS │
│ └─► Run actions defined on the transition │
│ │
│ 6. ENTRY ACTIONS │
│ └─► Run new state's entry actions │
│ │
│ 7. LISTEN ENTRY │
│ └─► Run listen.entry on target (if non-transient) │
│ │
│ 8. LISTEN TRANSITION │
│ └─► Run listen.transition (always, unless transient) │
│ │
│ 9. ALWAYS TRANSITIONS │
│ └─► Check for @always transitions │
│ └─► If found, repeat from step 1 │
│ └─► Original event preserved for behaviors (v8+) │
│ │
│ 10. RAISED EVENTS │
│ └─► Process any events raised during actions │
│ └─► Each raised event goes through steps 1-10 │
│ │
│ 11. PERSIST │
│ └─► Save event and context to database │
│ │
└─────────────────────────────────────────────────────────────┘Example Walkthrough
// Machine definition
'states' => [
'pending' => [
'exit' => ['logLeavingPending'],
'on' => [
'PAY' => [
'target' => 'paid',
'guards' => 'hasValidAmount',
'actions' => ['processPayment', 'generateReceipt'],
],
],
],
'paid' => [
'entry' => ['sendConfirmation', 'notifyWarehouse'],
'on' => [
'@always' => [
'target' => 'processing',
'guards' => 'autoProcessEnabled',
],
],
],
'processing' => [
'entry' => ['startProcessing'],
],
],
'behavior' => [
'calculators' => [
'calculateTax' => CalculateTaxCalculator::class,
],
]When PAY event is sent:
- Calculators:
calculateTaxruns, setstaxin context - Guards:
hasValidAmountchecks if amount > 0 - Exit:
logLeavingPendingruns - Transition:
processPayment,generateReceiptrun - Entry:
sendConfirmation,notifyWarehouserun - Always:
autoProcessEnabledguard checks — receives originalPAYevent (v8+) - If always matched: Jump to
processing, runstartProcessing - Persist: Event saved to database
5. Persistence
Every state change is automatically persisted:
// Sends PAY event
$machine->send(['type' => 'PAY', 'amount' => 99.99]);
// Creates record in machine_events:
// {
// type: 'PAY',
// payload: {amount: 99.99},
// context: {paid_amount: 99.99, ...},
// machine_value: ['order.paid'],
// root_event_id: 'xxx',
// sequence_number: 2
// }Disabling Persistence
For testing or calculations:
··· 1 hidden line
MachineDefinition::define(
config: [
'should_persist' => false,
// ...
],
);6. Restoration
Restore a machine from any point in its history:
// Get the root event ID
$rootEventId = $machine->state->history->first()->root_event_id;
// Later: restore
$restored = OrderMachine::create(state: $rootEventId);What happens:
- All events with this
root_event_idare loaded - Events are replayed in sequence order
- Final context is reconstructed
- Machine is at the exact state it was
Restoration vs Re-execution
Restoration does NOT re-run actions. It only rebuilds state:
// Original: runs sendEmail action
$machine->send(['type' => 'CONFIRM']);
// Restored: does NOT re-run sendEmail
$restored = OrderMachine::create(state: $rootEventId);
// Just rebuilds state from stored data7. Final States
When a machine reaches a final state:
'delivered' => [
'type' => 'final',
'output' => 'computeDeliveryOutput',
]What happens:
- Entry actions run (if any)
- Output behavior computes final output
- Machine is "done" - no more transitions possible
machine.finishinternal event fires
Check if done:
$state = $machine->state;
$state->currentStateDefinition->type === StateDefinitionType::FINAL;Concurrent Execution Safety
EventMachine prevents concurrent modifications using a database-backed mutex (machine_locks table) managed by MachineLockManager:
// Process A
$machine->send(['type' => 'PAY']);
// Process B (same time, same machine)
$machine->send(['type' => 'CANCEL']);
// Throws MachineAlreadyRunningException$machine->send() acquires a lock with timeout=0 (non-blocking). If another process already holds the lock, the call fails immediately with MachineAlreadyRunningException rather than waiting.
The lock is only active when the queue driver is async (redis or database) or parallel_dispatch.enabled is true. In unit tests with a sync queue and parallel dispatch disabled, no lock is acquired — this avoids re-entrant deadlocks when sync dispatch chains call send() on the same machine.
HTTP endpoints handle MachineAlreadyRunningException gracefully:
- GET endpoints return
200 OKwith the last committed state andisProcessing: true - POST endpoints return
423 Lockedwith the last committed state andisProcessing: true
See Endpoints for details on lock contention handling.
// MachineLockManager acquires a row-level lock in machine_locks
// Lock key: root_event_id of the machine instance
// timeout: 0 (non-blocking — fail immediately if already held)
// Re-entrant: Machine::$heldLockIds prevents deadlock in sync chainsLifecycle Hooks
Entry/Exit Actions
'states' => [
'active' => [
'entry' => ['onEnterActive'], // When entering
'exit' => ['onExitActive'], // When leaving
],
]Internal Events
| Event | When |
|---|---|
{machine}.start | Machine initialized |
{machine}.entry.start | Root entry actions starting (if defined) |
{machine}.entry.finish | Root entry actions completed |
{machine}.exit.start | Root exit actions starting (if defined, on final state) |
{machine}.exit.finish | Root exit actions completed |
{machine}.listen.entry.start/finish | Listener entry actions |
{machine}.listen.exit.start/finish | Listener exit actions |
{machine}.listen.transition.start/finish | Listener transition actions |
{machine}.listen.queue.{action}.dispatched | Queued listener dispatched |
{machine}.listen.queue.{action}.started | Worker picked up queued listener |
{machine}.listen.queue.{action}.completed | Worker finished queued listener |
{machine}.finish | Reached final state |
State History Access
$state = $machine->state;
// All events that led to current state
$history = $state->history;
// First event (root)
$first = $history->first();
$first->root_event_id; // Unique identifier
$first->type; // Event type
$first->payload; // Event data
// Latest event
$latest = $history->last();
// Iterate all events
foreach ($history as $event) {
echo "{$event->type} at {$event->created_at}";
}Best Practices
Keep Transitions Fast
Actions should be quick. For slow operations:
··· 1 hidden line
class ProcessPaymentAction extends ActionBehavior
{
public function __invoke($context, $event): void
{
// Quick: record intent
$context->set('payment_pending', true);
// Dispatch slow work to queue
ProcessPaymentJob::dispatch($event->payload);
}
}Idempotent Actions
Design actions to be safe to run multiple times:
// Bad: not idempotent
$context->set('count', $context->get('count') + 1);
// Good: idempotent
$context->set('processed_at', now());
$context->set('processed', true);Handle Failures Gracefully
··· 1 hidden line
class SendEmailAction extends ActionBehavior
{
public function __invoke($context, $event): void
{
try {
Mail::send(...);
$context->set('email_sent', true);
} catch (Exception $e) {
$context->set('email_sent', false);
$context->set('email_error', $e->getMessage());
// Raise event for retry handling
$this->raise(['type' => 'EMAIL_FAILED']);
}
}
}Detailed Guide
For comprehensive design guidelines with Do/Don't examples, see Best Practices Overview.
Testing
For testing the full machine lifecycle with TestMachine, see Testing Overview.