From event-machine
Builds, tests, and debugs event-driven state machines with EventMachine Laravel package for declarative workflows, parallel states, child delegation, event sourcing, timers, and HTTP endpoints.
npx claudepluginhub tarfin-labs/event-machine --plugin event-machineThis skill uses the workspace's default tool permissions.
EventMachine is a PHP/Laravel package for event-driven state machines inspired by XState/SCXML. It models workflows as explicit states and transitions with automatic event sourcing, Laravel-native integration, parallel execution via queue dispatch, child machine delegation, declarative timers/schedules, and a fluent test API.
Designs finite state machines and statecharts for modeling entity lifecycles, workflows, and system behaviors using Harel semantics, PlantUML, and Mermaid notation.
Models complex UI flows as finite state machines with states, events, transitions, actions, and guards. Useful for forms, data fetching, authentication flows, and wizards.
Guides validation-first development: define state machines, invariants, typestates before implementation code. For protocols, workflows, concurrent systems.
Share bugs, ideas, or general feedback.
EventMachine is a PHP/Laravel package for event-driven state machines inspired by XState/SCXML. It models workflows as explicit states and transitions with automatic event sourcing, Laravel-native integration, parallel execution via queue dispatch, child machine delegation, declarative timers/schedules, and a fluent test API.
Always link to docs using https://eventmachine.dev/... — never the github-pages URL.
This skill contains the full EventMachine documentation in docs/ plus agent-specific cheat-sheets in references/. Sections 1-7 below are a starting point — every non-trivial task should include reading the relevant docs/ file(s) before writing code.
Three levels of detail:
references/ — distilled agent cheat-sheets by task type. Use for quick pattern lookup when writing code.docs/ — canonical, human-authored documentation. Read for deep understanding. Always authoritative.Proactive doc-reading triggers — before writing code, read based on your task:
| Task | Read first |
|---|---|
| Write a scenario | docs/advanced/scenarios.md + docs/advanced/scenario-plan.md (Pitfalls section) |
| Debug scenario not intercepting | docs/advanced/scenario-runtime.md (Debugging Scenarios + machine_events verification) |
| Design state topology | docs/best-practices/state-design.md + docs/building/defining-states.md |
| Add machine/job delegation | docs/advanced/machine-delegation.md + docs/advanced/async-delegation.md |
| Add parallel states | docs/advanced/parallel-states/index.md + docs/best-practices/parallel-patterns.md |
| Write tests | docs/testing/overview.md + docs/testing/recipes.md |
| Debug transition issues | docs/reference/execution-model.md + docs/testing/troubleshooting.md |
| Wire HTTP endpoints | docs/laravel-integration/endpoints.md |
| Anything with @done/@fail/@timeout | docs/advanced/machine-delegation.md + docs/advanced/typed-contracts.md |
Skip doc-reading only for mechanical changes (rename, typo, simple extraction).
| Element | Style | Pattern | Example |
|---|---|---|---|
| Event class | PascalCase | {Subject}{PastVerb}Event | OrderSubmittedEvent |
| Event type | SCREAMING_SNAKE_CASE | {SUBJECT}_{PAST_VERB} | ORDER_SUBMITTED |
| State (leaf) | snake_case | adjective / participle | awaiting_payment |
| State (parent) | snake_case | noun (namespace) | payment |
| Action class | PascalCase | {Verb}{Object}Action | SendNotificationAction |
| Guard class | PascalCase | {Is/Has/Can}{Condition}Guard | IsPaymentValidGuard |
| ValidationGuard class | PascalCase | {Prefix}{Condition}ValidationGuard | IsAmountValidValidationGuard |
| Calculator class | PascalCase | {Subject}{Noun}Calculator | OrderTotalCalculator |
| Output class | PascalCase | {Subject}{Noun}Output | InvoiceSummaryOutput |
| Machine class | PascalCase | {Domain}Machine | OrderWorkflowMachine |
| Machine ID | snake_case | {domain_name} | order_workflow |
| Context class | PascalCase | {Domain}Context | OrderWorkflowContext |
| Inline behavior key | camelCase+type suffix | {verb}{Obj}{Type} | sendEmailAction |
Timer / then event | SCREAMING_SNAKE_CASE | Same as event types | ORDER_EXPIRED |
| Context / payload keys | camelCase | $descriptiveName | totalAmount |
| Config keys | snake_case | {descriptive_name} | should_persist |
awaiting_payment ✓, submit ✗.processing not process, submitted not submit.awaiting_payment not just payment.ORDER_SUBMITTED not SUBMIT_ORDER.ORDER_SUBMITTED not ORD_SUB.Is, Has, Can, Should; no other prefixes.totalAmount, transactionId (not total_amount).should_persist, after (lowercase key, NOT shouldPersist).Full naming guide: docs/building/conventions.md (1100+ lines of rationale and examples).
All 13 pages live under docs/best-practices/. Top-level summary:
| # | Topic | Principle |
|---|---|---|
| 1 | State Design | Model conditions, not steps — avoid state explosion |
| 2 | Event Design | Events are past-tense facts, not commands |
| 3 | Transition Design | Self-transition to restart; targetless to update context |
| 4 | Guard Design | Guards MUST be pure — no I/O, no context mutation |
| 5 | Action Design | Actions are idempotent side effects; never throw to block; keep external I/O in delegations for scenario interception |
| 6 | Context Design | Lean context; flags that change transitions belong in states |
| 7 | Event Bubbling | Leaf-first handler resolution — first match wins |
| 8 | Machine Decomposition | Split on own lifecycle, reuse, complexity, or independent failure |
| 9 | Machine System Design | Commands flow down (input); states flow up (@done) |
| 10 | Time-Based Patterns | Timers are event sources; intervals ≥ 1 min; idempotent actions |
| 11 | Parallel Patterns | Region independence, separate context keys per region |
| 12 | Testing Strategy | Four layers: unit → integration → E2E → LocalQA |
| 13 | Naming & Style | See Section 1 above |
Guard purity. Guards must be pure functions: same context + event → same boolean. No now(), no HTTP, no DB writes, no context mutation. EventMachine enforces this at runtime via context snapshot/restore across multi-branch transitions. If you need a computed value, use a Calculator — it runs BEFORE guards.
Do:
public function __invoke(OrderContext $context): bool {
return $context->total >= $this->minimum;
}
Don't:
public function __invoke(OrderContext $context): bool {
$context->set('checkedAt', now()); // mutation
return Http::get('/valid')->successful(); // I/O
}
Actions never throw to block transitions. Actions run AFTER guards approve the transition. Throwing in an action does NOT roll back the state change; it leaves the machine in an inconsistent state. Use guards to block; use actions for idempotent side effects (DB writes with idempotency keys, queued notifications, external APIs).
Don't (action-as-flow-control — leaves machine in inconsistent state):
class ApplyCounterOfferAction extends ActionBehavior {
public function __invoke(OrderContext $context, OrderEvent $event): void {
if ($context->retailerId !== $event->payload['retailerId']) {
throw new UnauthorizedRetailerException(); // ← anti-pattern
}
$context->set('counterOffer', $event->payload['amount']);
}
}
Do (guard blocks before transition; action only writes):
class IsRetailerAuthorizedGuard extends GuardBehavior {
public function __invoke(OrderContext $context, OrderEvent $event): bool {
return $context->retailerId === $event->payload['retailerId'];
}
}
// In transition config:
'COUNTER_OFFER' => [
'target' => 'awaiting_approval',
'guards' => IsRetailerAuthorizedGuard::class,
'actions' => ApplyCounterOfferAction::class,
],
Scenario impact: actions with lazy I/O fallbacks ("if not in context, call API") fire during scenario runs — scenarios only intercept delegations, not actions. Keep external I/O in job/machine delegations; see docs/best-practices/action-design.md → "Scenario-Friendly Design".
Events are past-tense facts, not commands. ORDER_SUBMITTED, not SUBMIT_ORDER. An event represents a state change that already happened. This disambiguates cross-machine communication — you always know who produced what.
Parallel regions MUST have separate context keys. Two regions writing status → last-writer-wins silently. Design each region to own its keys: paymentStatus, shippingStatus. Regions coordinate via raise() / sendTo(), never via shared context.
State explosion is the #1 design smell. If you have 3 booleans (isPriority, isFragile, isGift) don't make 8 states — keep them in context and let the processing state read them. States are for different behaviors; context is for data.
State::forTesting() + runWithState() — one behavior, no machine, no DBMachine::test() / Machine::startingAt() — flow through state transitionstests/LocalQA/, excluded from composer test)$machine->send() inside an action — use raise() for internal events, or return context changes. send() is the external API; actions are inside the macrostep.@done/@fail. Actions with I/O are invisible to scenarios and lack retry/timeout policies.MachineEvent instances — use send() / raise(). The engine manages event sourcing, context diffs, and persistence.send() from inside a transition — use @continue in scenarios, or raise() for event chains within a macrostep.state_entered_at by design. To express "deadline resets on event X", model X as a transition through a transit state. See Renewable Timers.| Concept | One-liner |
|---|---|
| State | Distinct phase; machine is in exactly one state (or one per parallel region) |
| Transition | Source → target movement triggered by an event |
| Event | Past-tense fact that triggers transitions: {type, payload} |
| Guard | Pure boolean condition — transition fires only if true |
| Action | Side effect during transition (entry/transition/exit) |
| Calculator | Compute derived values BEFORE guards |
| Output | Final computation when machine reaches a final state (type: final) |
| Context | Immutable data traveling with the machine — the "memory" |
Machine::create() loads definition, initializes context, NO entry actions yet.{machine}.start internal event.send() triggers pipeline per transition:
calculators → guards → exit actions (old state) → transition actions → entry actions (new state) → @always chains → raised eventsmachine_events. Restore from any point via root_event_id: OrderMachine::create(state: $rootEventId).When an event arrives, engine walks from current leaf state up the hierarchy until it finds a handler. First match wins. Use this for global handlers (root-level CANCEL) — don't over-rely on it.
Context is machine memory: application-specific data (orderId, totalAmount). Not business data (don't dump customer profiles in). Two flavors:
config: ['context' => ['totalAmount' => 0]] — key-value arrayclass OrderContext extends ContextManager with typed properties and Spatie Data validationContext mutation happens via Actions/Calculators. Guards see the current context but must NOT mutate it. Context travels to child machines via MachineInput.
@always transitionsTransient transitions evaluated automatically on state entry. Use for "if-then routing": enter deciding → @always picks approved vs rejected based on guards. Chains execute until a non-@always state is reached.
EventMachine accepts behaviors as inline closures (registered in behavior.actions/guards/calculators/outputs) or classes (extending ActionBehavior/GuardBehavior/etc.). Pick based on the work the behavior does, not the agent default.
| Choose closure when | Choose class when |
|---|---|
| Trivial wire-up: 1-5 lines, only reads/writes context, no I/O | Reusable across multiple machines or transitions |
| No constructor dependencies | Needs constructor DI (services, repos) |
| Used in exactly one place | Independently unit-tested |
| No need for descriptive class names | Behavior-name should be a first-class concept |
// Closure — preferred for trivial wire-up
'behavior' => [
'actions' => [
'wirePricingContextAction' => fn(OrderContext $ctx, ChildMachineDoneEvent $event) => [
'baseRate' => $event->output('baseInterestRate'),
'installments' => $event->output('installmentOptions'),
],
],
],
// Class — when the behavior is reusable, DI'd, or independently tested
class ChargePaymentAction extends ActionBehavior {
public function __construct(private readonly PaymentGateway $gateway) {}
public function __invoke(OrderContext $context): void {
$result = $this->gateway->charge($context->orderId);
$context->set('chargeId', $result->id);
}
}
Don't reach for a DTO + OutputBehavior subclass + Action class to wire a few keys child→parent. A closure on the child's final state 'output' plus an inline 'wireXxxAction' registered in behavior.actions is usually all you need. Save classes for behaviors that earn the boilerplate (DI, reuse, named contracts).
Inline behavior keys still follow naming conventions (Section 1): {verb}{Obj}{Type} — wirePricingContextAction, isAmountValidGuard, orderTotalCalculator. The type suffix is mandatory.
use Tarfinlabs\EventMachine\Actor\Machine;
use Tarfinlabs\EventMachine\Definition\MachineDefinition;
class OrderMachine extends Machine
{
public static function definition(): MachineDefinition
{
return MachineDefinition::define(
config: [
'id' => 'order',
'initial' => 'pending',
'context' => ['orderId' => null, 'total' => 0],
'states' => [
'pending' => ['on' => ['SUBMIT' => 'processing']],
'processing' => [
'entry' => ReserveInventoryAction::class,
'on' => ['COMPLETE' => 'completed', 'FAIL' => 'failed'],
],
'completed' => ['type' => 'final'],
'failed' => ['type' => 'final'],
],
],
);
}
}
use Tarfinlabs\EventMachine\Behavior\ActionBehavior;
use Tarfinlabs\EventMachine\ContextManager;
class ReserveInventoryAction extends ActionBehavior
{
public function __construct(
private readonly InventoryService $inventory,
) {}
public function __invoke(ContextManager $context): void
{
$reserved = $this->inventory->reserve($context->get('orderId'));
$context->set('reservationId', $reserved->id);
}
}
use Tarfinlabs\EventMachine\Behavior\GuardBehavior;
use Tarfinlabs\EventMachine\ContextManager;
class IsPaymentValidGuard extends GuardBehavior
{
public function __invoke(ContextManager $context, int $min = 0): bool
{
return (int) $context->get('total') >= $min;
}
}
OrderMachine::test(['orderId' => 'ORD-1', 'total' => 100])
->assertState('pending')
->send('SUBMIT')
->assertState('processing')
->assertBehaviorRan(ReserveInventoryAction::class)
->assertContext('reservationId', 'RES-123')
->send('COMPLETE')
->assertState('completed')
->assertFinished();
use Tarfinlabs\EventMachine\ContextManager;
use Spatie\LaravelData\Attributes\Validation\{Email, Min};
class OrderContext extends ContextManager
{
public function __construct(
public ?string $orderId = null,
#[Min(0)] public int $total = 0,
#[Email] public ?string $customerEmail = null,
) { parent::__construct(); }
}
MyMachine::test($context = []) — Boot + return TestMachine for fluent chainMyMachine::startingAt('nested.state') — Skip setup, jump to a specific stateState::forTesting($context) — Build a state for unit-testing a single behaviorMyBehavior::runWithState($state, $event) — Invoke a behavior directly; unit-leveldocs/testing/overview.md)assertState($stateName) — Current state value matchesassertInState($stateName) — Like assertState but works across parallel regionsassertContext($key, $value) — Context key matchesassertBehaviorRan(Class::class) — Action/Calculator/Guard executedassertGuarded($event) — Transition blocked by a guardassertGuardedBy($event, GuardClass::class) — Specific guard blocked itassertHasTimer($eventType) — Timer registered for eventassertFinished() — Machine reached a final stateassertRaised(ActionClass::class) — Action raised an internal event (use ActionClass::assertRaised() for isolated)advanceTimers(Timer::days(7)) — Simulate time; fires due timersfaking([MyAction::class, MyGuard::class]) — Replace behaviors with inspectable spiesMyChildMachine::fake(output: new OrderOutput(...), finalState: 'completed') — Stub child delegationMyChildMachine::assertInvoked() / assertInvokedWith([...]) — Verify child was calledsimulateChildDone/Fail/Timeout() — Drive parent through delegation paths without running childInteractsWithMachines trait on TestCase — Auto-resets all fakes between testsCommunicationRecorder — Inspect sendTo() / raise() calls without side effects// 1. Unit — one behavior, no machine
$state = State::forTesting(['total' => 50]);
expect(IsPaymentValidGuard::runWithState($state))->toBeFalse();
// 2. Integration — full flow in memory
OrderMachine::test(['total' => 100])->send('SUBMIT')->assertState('processing');
// 3. E2E — real persistence + restore
$m = OrderMachine::create();
$m->send('SUBMIT');
$restored = OrderMachine::create(state: $m->state->history->first()->root_event_id);
expect($restored->state->matches('processing'))->toBeTrue();
// 4. LocalQA — real MySQL + Redis + Horizon (tests/LocalQA/, excluded from composer test)
Test stubs live under tests/Stubs/ — excellent reference for real patterns.
use Tarfinlabs\EventMachine\Traits\HasMachines;
use Tarfinlabs\EventMachine\Casts\MachineCast;
class Order extends Model
{
use HasMachines;
protected $casts = [
'state_mre' => MachineCast::class . ':' . OrderMachine::class,
];
}
$order->state_mre->send(['type' => 'SUBMIT']);
MachineDefinition::define(
config: [...],
endpoints: [
'SUBMIT', // POST /submit
'APPROVE' => ['method' => 'PATCH', 'middleware' => ['auth:admin']],
'CANCEL' => ['action' => CancelEndpointAction::class],
],
);
// routes/console.php or web.php:
MachineRouter::register(OrderMachine::class, [
'prefix' => 'orders', 'model' => Order::class,
'attribute' => 'order_mre', 'create' => true,
'modelFor' => ['SUBMIT', 'APPROVE', 'CANCEL'],
]);
| Command | Purpose | When to use |
|---|---|---|
machine:validate | Validate machine config | After editing machine definition — catches config errors before runtime |
machine:paths | Enumerate all paths (static analysis) | After writing a scenario — confirm override states are on reachable paths |
machine:scenario-validate | Validate scenario structure | After every scenario file change — catches source/event/target mismatches |
machine:scenario | Scaffold a new scenario | Starting a new scenario — generates plan from BFS path analysis |
machine:coverage | Path coverage report | Before adding transitions — verify no dead paths created |
machine:xstate | Export to XState v5 JSON (Stately Studio) | For team discussion — visualize state topology |
machine:process-timers | Sweep due after/every timers | Auto-registered — runs on schedule |
machine:process-scheduled | Fire scheduled events | Auto-registered — runs on schedule |
machine:timer-status | Show timer state per instance | Debugging timer issues — check fire counts and next-fire times |
machine:archive-events | Archive old events (--dry-run, --sync) | Maintenance — reduce machine_events table size |
machine:archive-status | Archive stats; --restore=<rootEventId> | After archiving — verify or restore specific machines |
OrderMachine::query()
->inState('awaiting_payment')
->active()
->enteredBefore(now()->subDays(7))
->paginate(20);
The default is sync. Most users assume async because existing examples include 'queue' =>. Omit queue to run sync.
| Config | Mode | When |
|---|---|---|
'machine' => X::class | sync (in-process) | sync arithmetic, validation, transformation, fast lookups (<1s) |
'machine' => X::class, 'queue' => 'name' | async (queue dispatch) | external API, polling, retry, multi-step async (seconds-minutes) |
'machine' => X::class, 'queue' => 'name' + no @done | fire-and-forget | parent doesn't care about result; child runs independently |
'machine' => X::class + child uses ShouldQueue | mixed (anti-pattern) | ambiguous — avoid |
'processing_payment' => [
'machine' => PaymentMachine::class,
'input' => PaymentInput::class, // typed & validated
'queue' => 'payments', // async (omit for sync)
'@done' => ['target' => 'shipping', 'actions' => CapturePaymentAction::class],
'@fail' => ['target' => 'payment_failed', 'actions' => HandleFailureAction::class],
'@timeout' => ['after' => 300, 'target' => 'payment_timed_out'],
],
queue): parent blocks in-process until child hits finalqueue: true): parent transitions to delegation state, child runs on worker, @done fires on completionqueue key present + NO @done — parent continues immediately, child runs independently::: tip Sync child machines
Sync child machines that need to do work immediately must have @always transitions on their idle/initial state — start() enters the initial state but does NOT fire any event. See docs/best-practices/sync-child-machines.md for the canonical bootstrap pattern.
:::
@done / @fail / @timeout@done — child reached ANY final state (type: final)@done.{stateName} — child reached specific final state (e.g., @done.approved)@fail — child reached a failure state OR threw (requires @fail target)@timeout — async child didn't complete within after: N secondsActions on @done / @fail can type-hint MachineOutput / MachineFailure for typed injection from child's output.
'processing' => [
'type' => 'parallel',
'@done' => 'fulfilled', // fires when ALL regions hit final
'@fail' => 'failed', // fires when ANY region fails
'states' => [
'payment' => ['initial' => 'pending', 'states' => [...]],
'shipping' => ['initial' => 'preparing', 'states' => [...]],
],
],
Enable dispatch mode via config/machine.php → parallel_dispatch.enabled => true:
ParallelRegionJoboutput keyword — three semantics, one keywordThe 'output' key has three different meanings depending on where it appears. Confusing them is the #1 source of InvalidOutputDefinitionException errors.
| Where it lives | What it does | Parallel-region restricted? |
|---|---|---|
| (1) On a final state of THIS machine | Defines what $machine->output() returns. Restricted on transient states (@always) and parallel region states. | ✓ Yes — only the parent parallel state itself can define output |
(2) On a state with 'machine' => (child machine invocation) | Filters / transforms the child's final context before injecting into ChildMachineDoneEvent.output. Operates on the child machine, not the parent state. | ✗ No — works fine inside parallel regions because it's about the child |
| (3) On an endpoint config | Shapes the HTTP response (any state, not just final). | ✗ No |
Decision tree:
Is this state a final state in MY machine?
├── Yes → meaning (1). Defines what $machine->output() returns.
│ Inside parallel region? → only the PARENT parallel state can define output.
│ Use array filter for simple key picking, OutputBehavior class for computation.
│
Does this state have 'machine' => Child::class?
├── Yes → meaning (2). Defines what the CHILD exposes to the parent.
│ No parallel restriction. Lives on the parent state but operates on child context.
│ Format: array filter | closure | OutputBehavior class | MachineOutput DTO.
│
Is this an endpoint config?
└── Yes → meaning (3). Shapes HTTP response. Any state.
Common mistake: Adding 'output' => [...] to a delegation state inside a parallel region, expecting child→parent context merge — but the parser sees it as meaning (1) and throws InvalidOutputDefinitionException::parallelRegionState. Fix: the child machine itself should declare output on its final state (meaning 2 from the child's perspective), and the parent state's @done action picks it up via typed MachineOutput injection or ChildMachineDoneEvent::output().
Format choice (meanings 1, 2, and 3):
['key1', 'key2'] array → context key filter (passes through toResponseArray() — note that ModelTransformer serializes Eloquent models to IDs).OutputBehavior::class → computed output with DI; can return array or MachineOutput instance.MachineOutput::class → typed DTO with named properties auto-resolved from context.Full reference: docs/behaviors/outputs.md. Cheat-sheet: references/output-keyword.md.
dispatchToParent is transient — fire-and-forget job; parent may have already transitioned away → event silently dropped.paymentStatus + shippingStatus, never shared status.raise() / sendTo() for coordination.MachineCurrentState may lag under parallel dispatch — assert via restored machine (Machine::create(state: $rootEventId)), not by reading the table.ValidationGuardBehavior aborts the whole transition (422 via endpoints). Plain GuardBehavior failure = graceful.job key) skip dispatch in test mode — use simulateChildDone() to step.@fail does NOT inject typed MachineFailure — the engine synthesizes a generic ChildMachineFailEvent. If your @fail action type-hints a MachineFailure subclass → TypeError. Workaround: override the action with a context-write proxy (StoreFailureAction::class => ['failureReason' => '...']).continuation() = real dispatches after target — if your scenario's target state has event handlers leading to delegations (retry buttons, resend actions), those delegations fire for real without continuation. This applies to both normal and forwarded endpoints. Red flags: target is an error/failed state with retry, or awaiting state with resend.machine_events timestamps — after running a scenario, query child.*.start / child.*.done for the root_event_id. Same-second = scenario intercepted. A gap = real delegation fired (silent bug).@continue lives on leaf states, never on the parallel parent — the route matcher is suffix-based, and a parallel parent path is always a prefix of active child routes. Declaring @continue on the parent silently does nothing in 9.10.0 and earlier; 9.10.1+ rejects it at machine:scenario-validate time. To fire the parent's transition event (typically a guarded "all regions ready" event), put its @continue on a leaf state inside one of the regions — usually the final state of one region. The player walks regions in round-robin order, so the other regions advance through their own @continues first. See docs/advanced/scenario-plan.md → "Parallel @continue" for the worked pattern.'machine:' (and 'job:') states the plan can either declare an inline outcome (['outcome' => '@done.X', 'output' => [...]] — child never runs, fastest, no DB rows) or a child scenario class (AtSomeStateScenario::class — child runs with overrides, may pause). Pick the inline form when the child's logic is a black box for this scenario; pick the class form when the child's @always chain / guards / parallel regions are part of what you want to verify. For 'queue:' (async) parents the child scenario class form requires 9.10.3+ — earlier versions silently dropped the scenario at dispatch time and the worker booted the child with full I/O.Full details: docs/advanced/scenario-plan.md → "Pitfalls" section.
| Error | Likely cause | Fix |
|---|---|---|
TypeError: Argument must be of type <MachineFailure>, null given | Scenario @fail doesn't inject typed failure | Override the action with context-write proxy |
ScenarioFailedException: Event mismatch | Scenario slug attached to wrong endpoint | Check scenario's $event matches endpoint's registered event type |
ScenarioFailedException: Source mismatch | Machine not in expected source state | Check $source property vs current machine state |
NoScenarioPathFoundException | BFS can't reach target from source | Run machine:paths to find actual paths; add guard overrides for branching |
ScenarioTargetMismatchException | Machine didn't reach $target after execution | Check plan overrides force the intended path; override branch guards |
MissingMachineContextException | Required context key missing | Read the enriched hint in the error; add key via $requiredContext or input closure |
MachineAlreadyRunningException | Concurrent HTTP request to same machine | Normal under load — endpoints return 423 (POST) or 200 (GET) |
MachineValidationException (422) | ValidationGuardBehavior failed | Check validation rules; this is a user-input error, not a bug |
MaxTransitionDepthExceededException | Infinite @always or raise() chain | Check for circular guard logic; increase depth limit if legitimate |
Full exception reference: docs/reference/exceptions.md
This is the conceptual split that makes @queue work on listeners and structurally impossible on actions. Get it wrong and you'll keep wanting to invent a 'jobs' slot or queue an entry action — neither one exists.
| Action | Listener | |
|---|---|---|
| Role | Behavior — part of the transition | Observer — runs after the transition is committed |
| Mutates context | Yes, that's the point | Allowed, but lossy when queued (last-writer-wins) |
| Sequence-sensitive | Yes — later actions read earlier writes | No — each listener is independent |
| Failure | Throws can abort the transition | Throws don't undo it; failed jobs land in failed_jobs |
| Async-safe | No | Yes |
Why a queued action is structurally unsafe: the worker would race with later inline actions on the same context, restore the current persisted state (not the dispatch-time state — possibly several transitions later), and any throw on the worker could not undo a transition already recorded. Listeners avoid all three because they observe a committed transition.
@queue works only in listen config@queue is a framework-reserved key inside listener tuples — listen.entry, listen.exit, listen.transition. Anywhere else (state entry/exit actions, transition actions, guards, calculators, output tuples) it throws InvalidBehaviorDefinitionException at definition time. Before 9.11.0 it was silently dropped, which made it look like the action was queued when it actually ran inline.
Where you see @queue | What it does |
|---|---|
listen.entry/exit/transition tuple | ✓ Dispatches a ListenerJob to run on a worker |
State entry / exit action tuple | ✗ InvalidBehaviorDefinitionException |
Transition actions / guards / calculators tuple | ✗ InvalidBehaviorDefinitionException |
| Output / endpoint output tuple | ✗ InvalidBehaviorDefinitionException |
Async work in entry actions — pick by what the machine has to do with the result:
| Option | When to use | Cost |
|---|---|---|
Job actor (state's job key) | Outcome routes the next state via @done / @fail. | Needs its own state — the framework has to wait for the result. |
Queued listener (listen.entry + @queue) | Work runs after entry but doesn't drive a transition. Worker can write back to context. | One layer of indirection (ListenerJob → restore → run). Last-writer-wins on context. |
Wrapper Action (regular action that calls dispatch()) | Pure fire-and-forget — no machine state depends on the result. | One thin class. Cheapest and most common option. |
::: tip Wrapper Action recipe — fire-and-forget without a separate state
A user asking "can't I just put MyJob::class in entry?" wants this:
final class DispatchPromissoryNoteAction extends ActionBehavior
{
public function __invoke(ContextManager $context): void
{
dispatch(new CreateInstallmentPromissoryNoteJob(applicationId: $context->applicationId));
}
}
'approved' => [
'entry' => [..., DispatchPromissoryNoteAction::class, ...],
],
Don't propose a new 'jobs' slot — wrapper Actions express fire-and-forget more clearly, stay inside existing fake infrastructure (Machine::fakingAllActions, Bus::fake()), and don't blur the Action↔Listener semantic split.
:::
docs/building/defining-states.md → "Listeners" (semantic split) and "Async Work in Entry Actions" (recipes).This skill ships with the complete VitePress documentation at docs/ (materialized at release time, symlinked during development). The sections above are a starting point — docs/ is always the authoritative source.
| Task | Primary reads | Secondary reads | Key gotchas |
|---|---|---|---|
| Write a new scenario | scenarios.md, scenario-plan.md | testing/recipes.md | Simulated @fail typed injection, path divergence, I/O actions |
| Debug scenario not firing | scenario-runtime.md (Debugging) | machine:paths + machine_events query | See "Verifying scenario interception" |
| Design new state topology | best-practices/state-design.md, defining-states.md | hierarchical-states.md, parallel-states/index.md | State explosion, transient naming |
| Add delegation (machine/job) | machine-delegation.md, async-delegation.md | job-actors.md, testing/delegation-testing.md | @fail typed failure, region isolation |
| Add parallel states | parallel-states/index.md, parallel-patterns.md | parallel-states/parallel-dispatch.md | Disjoint context keys, dispatch mode |
| Write tests for existing machine | testing/overview.md, testing/test-machine.md | constructor-di.md, fakeable-behaviors.md | Faking at right layer |
| Wire HTTP endpoints | laravel-integration/endpoints.md | scenario-endpoints.md | MachineAlreadyRunning handling |
| Debug transition issues | reference/execution-model.md | testing/troubleshooting.md | Macrostep ordering, event bubbling |
All paths relative to docs/advanced/ unless otherwise specified.
| Topic | File |
|---|---|
| Naming & style (deep dive) | docs/building/conventions.md |
| Best practices (13 pages) | docs/best-practices/*.md |
| First machine walkthrough | docs/getting-started/your-first-machine.md |
| When NOT to use EventMachine | docs/getting-started/when-not-to-use.md |
| Upgrading | docs/getting-started/upgrading.md |
| States & transitions depth | docs/understanding/states-and-transitions.md |
| Events depth | docs/understanding/events.md |
| Context depth | docs/understanding/context.md |
| Machine lifecycle | docs/understanding/machine-lifecycle.md |
| Defining states | docs/building/defining-states.md |
| Writing transitions | docs/building/writing-transitions.md |
| Handling events | docs/building/handling-events.md |
| Working with context | docs/building/working-with-context.md |
| Machine configuration | docs/building/configuration.md |
| Actions | docs/behaviors/actions.md |
| Guards | docs/behaviors/guards.md |
| Validation guards | docs/behaviors/validation-guards.md |
| Calculators | docs/behaviors/calculators.md |
| Events (as behaviors) | docs/behaviors/events.md |
| Outputs | docs/behaviors/outputs.md |
| Machine delegation | docs/advanced/machine-delegation.md |
| Delegation patterns | docs/advanced/delegation-patterns.md |
| Delegation data flow | docs/advanced/delegation-data-flow.md |
| Async delegation | docs/advanced/async-delegation.md |
| Job actors | docs/advanced/job-actors.md |
| Parallel states overview | docs/advanced/parallel-states/index.md |
| Parallel event handling | docs/advanced/parallel-states/event-handling.md |
| Parallel dispatch | docs/advanced/parallel-states/parallel-dispatch.md |
| Parallel persistence | docs/advanced/parallel-states/persistence.md |
| Hierarchical states | docs/advanced/hierarchical-states.md |
| Entry / exit actions | docs/advanced/entry-exit-actions.md |
| Always transitions | docs/advanced/always-transitions.md |
| Raised events | docs/advanced/raised-events.md |
sendTo / cross-machine | docs/advanced/sendto.md |
| Time-based events | docs/advanced/time-based-events.md |
| Scheduled events | docs/advanced/scheduled-events.md |
| Typed contracts | docs/advanced/typed-contracts.md |
| Dependency injection | docs/advanced/dependency-injection.md |
| Custom context (typed) | docs/advanced/custom-context.md |
| Scenarios overview | docs/advanced/scenarios.md |
| Scenario commands | docs/advanced/scenario-commands.md |
| Scenario behaviors | docs/advanced/scenario-behaviors.md |
| Scenario runtime + debugging | docs/advanced/scenario-runtime.md — includes 4-tier validation framework and machine_events interception verification |
| Scenario endpoints | docs/advanced/scenario-endpoints.md |
| Scenario plan + pitfalls | docs/advanced/scenario-plan.md — read "Pitfalls" section before writing any scenario |
| Laravel integration | docs/laravel-integration/overview.md |
| Eloquent integration | docs/laravel-integration/eloquent-integration.md |
| Persistence | docs/laravel-integration/persistence.md |
| HTTP endpoints | docs/laravel-integration/endpoints.md |
| Available events (framework) | docs/laravel-integration/available-events.md |
| Archival | docs/laravel-integration/archival.md |
| Compression | docs/laravel-integration/compression.md |
| Artisan commands | docs/laravel-integration/artisan-commands.md |
| Testing overview | docs/testing/overview.md |
| TestMachine API | docs/testing/test-machine.md |
| Isolated (unit) tests | docs/testing/isolated-testing.md |
| Transitions & paths | docs/testing/transitions-and-paths.md |
| Fakeable behaviors | docs/testing/fakeable-behaviors.md |
| Constructor DI in tests | docs/testing/constructor-di.md |
| Delegation testing | docs/testing/delegation-testing.md |
| Parallel testing | docs/testing/parallel-testing.md |
| Time-based testing | docs/testing/time-based-testing.md |
| Scheduled testing | docs/testing/scheduled-testing.md |
| Persistence testing | docs/testing/persistence-testing.md |
| Recipes | docs/testing/recipes.md |
| LocalQA setup | docs/testing/localqa.md |
| Testing troubleshooting | docs/testing/troubleshooting.md |
| Execution model (internals) | docs/reference/execution-model.md |
| Exceptions | docs/reference/exceptions.md |
references/)docs/ is canonical documentation — read for understanding. references/ contains distilled agent-facing cheat-sheets — read for quick pattern lookup when writing code. When in doubt, start with references/INDEX.md.
| File | Use when | Docs equivalent (longer) |
|---|---|---|
references/INDEX.md | Routing to the right cheat-sheet or doc by task type | Section 8 tables above |
references/anti-patterns.md | About to write a known anti-pattern (action throws, guard I/O, shared parallel context, output boilerplate) — quick lookup of the alternative | docs/best-practices/*.md |
references/output-keyword.md | Confused by InvalidOutputDefinitionException or unsure which of the three 'output' semantics applies | docs/behaviors/outputs.md + docs/advanced/delegation-data-flow.md |
references/sync-vs-async-delegation.md | Adding 'machine' => and unsure if it runs sync or async, or bootstrapping a sync child with @always on idle | docs/advanced/machine-delegation.md + docs/best-practices/sync-child-machines.md |
references/testing.md | Writing assertions, setting up fakes | docs/testing/overview.md + test-machine.md |
references/delegation.md | Adding sync/async delegation, @done/@fail/@timeout | docs/advanced/machine-delegation.md |
references/parallel.md | Designing parallel regions, dispatch config | docs/advanced/parallel-states/index.md |
references/qa-setup.md | Setting up LocalQA test environment | docs/testing/localqa.md |
references/timers.md | Designing timers, renewable-timer pattern, sliding windows | docs/best-practices/time-based-patterns.md + docs/advanced/time-based-events.md |
references/scenarios.md | Writing or debugging MachineScenario plans, choosing inline outcome vs child scenario class, async propagation in 9.10.3+ | docs/advanced/scenarios.md + scenario-plan.md + scenario-runtime.md |
When a user asks you to build/modify an EventMachine workflow:
composer quality (pint + rector + test). Never just vendor/bin/pest.MachineInput, MachineOutput, MachineFailure for delegation boundaries.v prefix).When a user asks you to write or debug a scenario:
docs/advanced/scenarios.md + docs/advanced/scenario-plan.md — especially the "Pitfalls" section.machine:scenario — accept the scaffolder's BFS path choices as a starting point.machine:paths <Machine> — confirm every override state in your plan appears on a reachable path from source to target.MachineFailure params — if your scenario uses @fail and the @fail action type-hints a MachineFailure subclass, override the action with a context-write proxy.continuation() — if the target state has event handlers that lead to delegations (retry, resend, next-step), continuation is mandatory or those delegations fire for real.machine:scenario-validate — catches structural errors (source/event/target mismatch, unreachable paths).ScenarioPlayer::execute() — catches typed injection failures and unexpected action side-effects before HTTP-level testing.machine_events — in integration tests, assert child.*.start / child.*.done are same-second (proves scenario intercepted, no real dispatch).