Upgrading Guide
Support Policy
Only the latest major version receives bug fixes, new features, and security patches. All previous versions are end of life.
| Version | Status |
|---|---|
| 9.x | Active — bug fixes, features, security |
| 8.x and below | End of life — upgrade to latest |
Why only latest?
EventMachine evolved rapidly from v1 to v7 with a small team. Maintaining multiple branches is not sustainable. More importantly, the upgrade barrier is low: v4 through v7 have zero breaking changes to machine definitions — the only breaking changes were PHP/Laravel version requirements (v4) and behavior constructor resolution (v6). A typical multi-version upgrade takes minutes, not days.
Upgrading from any version
Each section below has step-by-step migration instructions with before/after examples. For multi-version jumps (e.g., v3 → v7), follow each guide in sequence. No data migration is required between any versions — the machine_events table format has not changed since v1.
Version Compatibility
| EventMachine | PHP | Laravel | Status |
|---|---|---|---|
| 9.x | 8.3+ | 11.x, 12.x, 13.x | Active |
| 8.x | 8.3+ | 11.x, 12.x | End of life |
| 7.x | 8.3+ | 11.x, 12.x | End of life |
| 6.x | 8.3+ | 11.x, 12.x | End of life |
| 5.x | 8.3+ | 11.x, 12.x | End of life |
| 4.x | 8.3+ | 11.x, 12.x | End of life |
| 3.x | 8.2+ | 10.x, 11.x, 12.x | End of life |
| 2.x | 8.1+ | 9.x, 10.x | End of life |
| 1.x | 8.0+ | 8.x, 9.x | End of life |
From 9.19.x to 9.20.0
Scenario path resolution is now weighted. Everything here is confined to machine:scenario, machine:scenario-validate and ScenarioPathResolver — no runtime code path resolves scenario paths, so a running machine behaves identically. What changes is what those commands pick and print.
--path=N is re-indexed
Paths are listed cheapest first rather than in the order the search found them. A pinned --path=3 selects a different route than it did before, or is out of range. Capture the listing before upgrading and re-match by signature, not by index. Routes tied on both weight and length have no promised order at all, so --path=N is not a durable selector within such a group.
The default scaffold changes
A caller passing no flags gets the cheapest route, which is often not the route it got before. machine:scenario therefore writes a different file for an unchanged machine, and a regenerate-and-diff CI check will report drift on the upgrade commit. Generated scaffolds are meant to be hand-edited — regenerate deliberately and diff before merging, or a blind regenerate overwrites author work.
--max-iterations is one budget for the whole resolution
It used to be one per branch of the trigger event, so the effective ceiling was maxIterations × branchCount. Multiply a pinned value by the branch count. Operators who never pinned it lose the same headroom silently, since the default stays 1000.
Truncation therefore becomes more likely, never less, and the smaller ceiling is the whole of the reason: wasTruncated() can report true where it reported false on an unchanged machine at an unchanged cap. Cost ordering does something different and equally worth knowing — it changes which paths are found inside a cap already being hit, deferring a target reachable only through an expensive state behind every cheaper frontier entry, so a target a cap previously reached may now fall outside it. It cannot move the flag itself, because draining the frontier costs the same number of expansions whatever order they come out in. "Truncated at the search limit" remains a different finding from "no path exists", and only the first is fixable by raising the number.
machine:scenario output changes, including for a single path
Each listed path's stats line gains a trailing weight N, and both commands' --max-iterations description no longer calls the search BFS. Less obviously: a single-path resolution now prints a listing where it previously printed nothing at all. The listing was gated on there being more than one path; it now always renders, minus the Use --path=N hint. Update any test asserting these strings, and any parser that treated an absent listing as "one path".
ScenarioPath gains a serialised property
$totalWeight appears in anything that serialises a path. It is derived in the constructor rather than accepted, so hydration that bypasses the constructor — unserialize(), reflection-based rebuilding — leaves it uninitialised and reading it throws. On rollback the mirror applies: a path serialised by 9.20 and read back by the old class carries totalWeight as an undeclared property. ScenarioPath is a readonly class, and a readonly class forbids dynamic properties outright, so unserialize() raises Error: Cannot create dynamic property unconditionally — on every supported PHP, whatever your error handler. Do not carry a serialised ScenarioPath across the version boundary; regenerate it.
From 9.18.x to 9.19.0
The sweep commands now fail instead of exiting 0 having done nothing
Breaking change for anything that reads their exit code — including a cron monitor, which is the point.
machine:process-scheduledexits1when the schedule's resolver throws. It used to catch the throwable, print it, and return an empty instance list, whichhandle()reported as "No matching instances found" and exited0. "The resolver blew up" and "nothing to process" were the same outcome, so a monitor stayed green while nothing was dispatched.machine:process-timersexits1when a machine'sdefinition()cannot be built. It used to print the error and exit0.
What can newly fail: a scheduled job that has been quietly broken starts reporting it. Run both commands by hand once before trusting the schedule again.
A parallel machine is dispatched once, not once per region
machine_current_states holds one row per active region, and machine:process-scheduled plucked root_event_id without distinct() on both its resolver and auto-detect paths. An instance active in N regions received N identical events. Timers were never affected.
ProcessTimersCommand::processClass() returns bool
It was void. The method is protected, so only a consumer subclassing the command to override it is affected — update the signature to match. A machine with no timers returns true; sharding runs the command per class, and having nothing to do is not a failure.
From 9.17.x to 9.18.0
A scenario can no longer make a machine unloadable
Bug fix, no action needed — and it un-sticks machines that are already broken.
Stored scenario params were re-validated on every machine load. A rule that reads the world — exists on a row someone later deleted, after_or_equal:today once midnight passes — went from passing to failing with nothing about the machine having changed, and the machine then threw ScenarioConfigurationException on every load. It was unrecoverable through the application, because the code that clears the stored scenario runs after the restore and could never be reached; the only way out was a direct UPDATE on machine_current_states.
Three changes: stored params are hydrated without being re-validated (validation still happens when params are accepted); a scenario that reached its target with no continuation is deactivated instead of staying armed forever; and anything that goes wrong hydrating a stored scenario now drops the scenario overrides rather than the machine.
MachineScenario::hydrateParams() gained a second parameter, bool $validate = true. The default preserves the old behaviour, so an override in your own scenario base class keeps working — but if you override hydrateParams(), add the parameter to match the signature.
machine:scenario-validate reports — and fails on — scenarios it could not load
Breaking change. A scenario file that cannot be loaded or constructed used to leave the list silently: the command reported a smaller total and exited 0. A scenario broken in its class body was indistinguishable from one nobody had written, and the comment in the source claimed the validate command caught these later — it could not, because it takes its list from the same method that dropped them.
The command now names each skipped file with its reason, and counts it as a failure:
12 scenario files found, 11 validated — 1 could not be loaded:
✗ AtAllocationUnderReviewScenario (not validated)
class not found — the file does not declare App\Machines\CarSales\Scenarios\AtAllocationUnderReviewScenario
Total: 11 passed, 1 not loadedWhat can newly fail: a CI job running machine:scenario-validate starts failing on scenarios that were already broken and already not being exercised. Nothing regressed — run it once locally before trusting the pipeline. A --scenario filter is unaffected: narrowing the list is the point, so the excluded files are not reported.
ScenarioDiscovery::skippedFor(string $machineClass) exposes the same list programmatically, as list<array{file: string, class: string, reason: string}>.
Dependency and toolchain fixes
symfony/process(dev) was constrained to^6.0|^7.0, which excludes what Laravel 12/13 install. It is now^7.0|^8.0. This only affected contributors to the package, not consumers — but with a gitignoredcomposer.lockit meant a local dependency tree that differed from CI's.Machine::resolveOutputBehavior()handed a non-string, non-callable output definition toresolve(), producing a container error that named neither the machine nor the state. It now throwsMachineOutputResolutionExceptionnaming the type it got.
From 9.16.x to 9.17.0
Path Analysis: Bounded Enumeration and Explicit Truncation
Read this first if you use assertAllPathsCovered() or a --min coverage gate. The analysis now finds routes it used to miss, so path counts rise and coverage percentages fall on machines that have them — substantially. Details and the re-baselining advice are under "Changes that can newly fail an existing suite" below; nothing regressed, the old numbers were measuring less than they claimed.
The crash this work started from: machine:paths previously terminated the process (exit 139) on machines where a transition declared inside a parallel region targets a state outside it, because the region walk escaped its own boundary and recursed without bound. Enumeration is now confined to the region and bounded by two ceilings, and every command reports when a ceiling fired.
New flags:
| Command | Flag | Default |
|---|---|---|
machine:paths | --max-depth=N | 200 |
machine:coverage | --max-paths=N, --max-depth=N | 1000, 200 |
machine:scenario | --max-iterations=N | 1000 |
machine:scenario-validate | --max-iterations=N | 1000 |
Every one of these is now validated: a non-numeric or out-of-range value fails the command instead of being coerced to 0. That matters most for machine:scenario --path, where zero is a valid index — --path=abc used to scaffold path 0 and report "Created:".
Changes that can newly fail an existing suite:
Path counts rise sharply — expect coverage percentages to drop. On the three production machines this release was validated against, the totals went 239 → 878, 99 → 390 and 223 → 862 paths (3.7–3.9×).
Read those "before" figures precisely: they are mid-release, measured once the crash was fixed but before the final-state change. They are not what 9.16.0 reports, because 9.16.0 reports nothing for these three machines —
machine:pathssegfaults on all of them (exit 139). If you are coming from 9.16.0 with a machine that crashed, your before-figure does not exist; if you are coming from one that did not crash, measure it.Several changes contribute, and the largest is the final-state one: a final state was treated as terminal by both the enumerator and the scenario resolver, so any route continuing out of one was invisible — yet
findTransitionDefinitionwalks the parent chain with no special case forFINAL, so a handler declared on an ancestor does fire from a final child, a compound@donewhose guards all fail leaves the machine resting there, and a final state that delegates still has its@done/@failroutes. Any one of those three shapes is enough; a machine with none of them is unaffected by that change. The rest of the rise comes from a parallel state's own transitions, an@alwaysdeclared on a parallel state, and the continuation beyond a region-declared escape — all previously dropped.How much a given machine moves therefore depends on which shapes it uses, and the range is wide: measured in isolation the final-state change alone ran from 1.0× (a machine with none of the three) to 12.5× across synthetic shapes, and a plain compound machine with no parallel state at all — a guarded compound
@doneplus a root-level handler — moved 2→8. Do not read any single ratio as a rule — measure your own machines withmachine:pathsbefore and after.The practical consequence is the same whichever cause dominates: every coverage percentage previously computed on such a machine was inflated, because the denominator was missing real paths.
assertAllPathsCovered()will now fail where it passed, andmachine:coverage --mingates will drop below their thresholds. Nothing regressed — the earlier numbers were measuring less than they claimed. Re-baseline the thresholds against the new figures, and usemachine:pathsto see which routes appeared.assertAllPathsCovered()andassertPathCoverage()now fail when the enumeration was truncated by either ceiling, instead of reporting coverage over a partial analysis. Both takemaxPathsandmaxDepth, so a machine that legitimately needs more can raise them:phpFindeksMachine::assertAllPathsCovered(maxPaths: 5000, maxDepth: 400);machine:coverage --minrejects non-numeric input instead of coercing it to0.--min=abcpreviously passed every gate silently; it now fails the command. It also refuses to judge a truncated analysis rather than printing a percentage that cannot mean what it claims.machine:scenarioseparates "no path exists" from "the search hit its iteration limit"; previously both printed "No path". Only the second is fixable, with--max-iterations. It fails when no route was found and warns while continuing when one was, since the printed list may then be missing others.machine:scenario-validategained the same--max-iterationsoption, which it previously lacked entirely.--max-pathsnow bounds region paths and machine paths together. It was applied per region before, so a parallel machine could enumerate far past the ceiling without noticing. A machine that previously fitted under the default 1000 may now report itself truncated — and since truncation fails the coverage assertions (above), that can newly fail a suite. Raise--max-paths, in the assertion or on the command, to get the old reach back.Targets inside a parallel region now resolve.
machine:scenariodescends into region initial states, marking region entry@region— distinct from@entry, which means exclusive compound descent. A route that previously reported "No path" for a reachable region-interior target now succeeds.This also changes the answer for targets that already resolved. The same descent adds routes that pass through a region on the way to a target outside it, so
resolveAll()can return more paths than before for an unchanged query — measured on a test machine, one target went from 1 path to 3. Two consequences for existing callers:machine:scenarionow prints "Found N paths" and offers a--path=Nchooser where it used to scaffold silently, and--pathindices are not stable across this upgrade — a script pinned to--path=0may scaffold a different route. Re-check any pinned index.machine:scenariovalidates the scenario name. It must be a PHP class name — a letter or underscore, then letters, digits or underscores. A name containing a path separator previously wrote the file outside theScenarios/directory and still reported "Created:", and a name containing PHP syntax was interpolated into the generated class file verbatim.Expect
machine:scenario-validateto find dead scenarios on the first run. On a machine that crashed, the command could not get far enough to load its scenarios, so one whose imports had rotted stayed silently broken. One consumer's upgrade surfaced a scenario dead since an unrelated namespace move (include(...): Failed to open stream). That is the fix working, not the upgrade breaking something.Calibrate
--max-iterationson large machines. The default of 1000 is low for a real machine: one consumer's machine reports "truncated at the search limit" on a query whose answer is already complete, and needs--max-iterations=50000before the warning clears — with the same 8 paths at every ceiling. The warning names the ceiling it hit so you can tell which number to raise past.
New path types: TRUNCATED (a branch cut at a ceiling; excluded from coverage accounting), plus REGION_EXIT and REGION_DEFERRED for paths inside a parallel region. See Transitions & Paths.
From 9.6.x to 9.7.0
Child Scenario Persistence on Interactive Pause
When a child scenario (referenced in plan() via ChildScenario::class) pauses at an interactive state, the child machine is now persisted to DB. Previously, child scenarios ran entirely in-memory (shouldPersist=false) — the child was volatile and forward endpoints could not interact with it.
What changed:
executeChildScenario()persists the child machine when it pauses at a non-final statemachine_childrenrecord created linking parent to child- Child's
scenario_classpersisted for continuation support - Parent context passed to child via
resolveChildContext()
Forward Endpoint Continuation Support
Forward endpoints (forward key on delegation states) now detect active continuation scenarios on the child machine. When a forwarded event arrives for a child with scenario_class and hasContinuation(), the child's continuation overrides are applied via executeContinuation().
Previously, forwarded events always processed with real behavior, even when the child had an active scenario.
Bug Fix: Continuation Overrides Overwritten by Plan on send()
Machine::send() calls restoreStateFromRootEventId() which re-registered plan overrides — overwriting continuation overrides set by executeContinuation(). Guards would use plan values (e.g., IsPinRequiredGuard=true) instead of continuation values (false).
Fixed: restoreStateFromRootEventId() now skips override re-registration when ScenarioPlayer::isActive() — active scenario execution already has the correct overrides.
Bug Fix: Closure Override Parameter Injection
Scenario closure overrides (StoreReportIdAction::class => function (FindeksContext $context) { ... }) were not receiving injected parameters. createClosureProxy() wrapped closures in anonymous class with __invoke(mixed ...$args) — injectInvokableBehaviorParameters reflected on the variadic signature and injected nothing.
Fixed: Proxy now exposes scenarioHandler property with the original closure. Parameter injection reflects on the original closure's type hints — ContextManager (and subclasses like FindeksContext), State, EventBehavior all inject correctly.
Bug Fix: Forward Events Missing in Parallel State Response
availableEventsForParallelState() only checked transitionDefinitions — delegation states' forward events were omitted. Frontend received empty availableEvents for parallel states with forwarded child machines.
Fixed: Forward event loop added, matching the logic in non-parallel availableEvents().
Bug Fix: Wrong Child Selection in Parallel Regions
resolveChildCurrentStateDef() and tryForwardEventToChild() queried machine_children by parent_root_event_id + status=running without filtering by child_machine_class. With multiple running children in parallel regions, the wrong child could be selected.
Fixed: Added child_machine_class filter to both queries.
From 9.6.0 to 9.6.1
Bug Fix: Child Scenario @continue Loop
executeChildScenario() was missing the @continue loop — child scenarios with @continue directives in their plan would pause at the first interactive state instead of advancing. This affected child scenarios that need to traverse interactive states (e.g., Findeks PIN flow where the child traverses querying_phones → awaiting_report_request → requesting → polling → awaiting_pin).
Fixed: executeChildScenario() now runs the same @continue loop as execute(), with delegation outcome interception active throughout. Child scenarios with mixed @continue + outcome entries now work correctly.
From 9.5.x to 9.6.0
Callable Delegation Outcome
Scenario plan() and continuation() outcome values can now be a Closure for runtime-conditional outcomes. The Closure uses InvokableBehavior parameter injection — type-hint what you need:
'confirming_pin' => [
'outcome' => function (ContextManager $context): string {
return $context->pin === now()->format('dmy') ? '@done' : '@fail';
},
IsPinRetryableGuard::class => true,
],See Callable Outcome for details.
Bug Fix: Guard Overrides in Outcome Arrays
Guard and action class keys in outcome arrays (e.g., IsPinRetryableGuard::class => true alongside 'outcome' => '@fail') were previously silently ignored. They are now extracted and registered as behavior overrides, so they take effect during @fail/@done routing.
Validator: Outcome Arrays on Non-Delegation States
machine:scenario-validate now correctly rejects outcome arrays (['outcome' => '...']) on non-delegation states. Previously only bare string outcomes ('@done') were caught.
From 9.4.x to 9.5.0
Explicit Scenario Deactivation (scenario: null)
You can now explicitly deactivate an active continuation scenario by sending scenario: null in the request payload. Previously, the only ways to deactivate were switching to a different scenario or waiting for a final state.
POST /api/orders/{orderId}/confirm-pin
{
"type": "PIN_CONFIRMED",
"scenario": null
}This is useful when QA wants to exit a continuation mid-flow and test real behavior without overrides. See Explicit Deactivation.
scenario: null vs omitting scenario
- Omitting
scenario: continuation auto-restores and applies overrides scenario: null: continuation deactivated, real behavior used
Scaffold continuation() Stub
php artisan machine:scenario now generates a continuation() method stub when the target state is interactive. This reminds you to define Phase 2 overrides for subsequent requests after the machine reaches the target.
Non-interactive targets (final, transient) do not generate the stub.
Bug Fix: json_decode on Eloquent-Cast Column
Fixed a double-decoding bug where scenario_params (which has an Eloquent 'array' cast) was passed through json_decode() again in maybeRegisterScenarioOverrides(). This caused a TypeError when the column contained a non-null value.
From 9.3.x to 9.4.0
New: Machine Scenarios
Declarative behavior overrides for QA and staging environments. Define MachineScenario classes that specify a journey (source → event → target) with plan() overrides, then activate from existing endpoints via a scenario field.
See Scenarios for the full documentation.
Setup:
- Add
MACHINE_SCENARIOS_ENABLED=trueto staging.env - Run the migration:
php artisan migrate(addsscenario_classandscenario_paramscolumns tomachine_current_states) - Create
MachineScenarioclasses (usephp artisan machine:scenarioto scaffold) - Run
php artisan machine:scenario-validateto verify
Old scenario system deprecated: The old scenarios_enabled / scenarioType system is deprecated and will be removed in the next major version. Migration steps:
- Remove
scenarios_enabledfrom machine config - Remove
scenarioTypefrom event payloads - Remove
withScenario()from tests - Create
MachineScenarioclasses as replacements
9.4.0 — Scenario Continuation
Non-breaking. Adds multi-request scenario flows via continuation() on MachineScenario.
What changed:
MachineScenario::continuation()— new method, returns[]by default (existing scenarios unaffected)MachineScenario::hasContinuation()/resolvedContinuation()— new public methodsMachineScenario::$isContinuation— new public flag (set by controller internally)ScenarioPlayer::executeContinuation()— new method for Phase 2 execution- Endpoint responses include
activeScenariofield when a continuation scenario is active
No migration required. All changes are additive. Existing scenarios without continuation() work exactly as before.
Optional: If you have scenarios targeting interactive states that currently require a second scenario for the next step, you can consolidate them by adding continuation() to the first scenario. See Continuation — Multi-Request Flows.
From 8.x to 9.0
Unified Output — result/contextKeys → output
v9 replaces three separate keywords (result, contextKeys, results) with a single unified output keyword. The type of the value determines the behavior:
| Before (v8) | After (v9) | Effect |
|---|---|---|
'result' => MyResult::class | 'output' => MyOutput::class | OutputBehavior class computes response |
'contextKeys' => ['a', 'b'] | 'output' => ['a', 'b'] | Array filters context keys |
'results' => [...] (behavior array) | 'outputs' => [...] | Behavior registration key renamed |
Class Renames
| Before (v8) | After (v9) |
|---|---|
ResultBehavior | OutputBehavior |
{Name}Result | {Name}Output |
Method Renames
| Before (v8) | After (v9) |
|---|---|
$machine->result() | $machine->output() |
assertResult($expected) | assertOutput($expected) |
ChildMachineDoneEvent::result() | Removed — use output() only |
Response Envelope Changes
The HTTP response envelope keys have been renamed for consistency:
// BEFORE (v8)
{
"data": {
"machine_id": "01JARX...",
"value": ["submitted"],
"context": { "totalAmount": 100 },
"available_events": [{ "type": "APPROVE", "source": "parent" }]
}
}// AFTER (v9)
{
"data": {
"id": "01JARX...",
"state": ["submitted"],
"output": { "totalAmount": 100 },
"availableEvents": [{ "type": "APPROVE", "source": "parent" }],
"isProcessing": false
}
}Config Key Migration Examples
State definitions:
// BEFORE (v8)
'approved' => [
'type' => 'final',
'result' => ApprovalResult::class,
],
// AFTER (v9)
'approved' => [
'type' => 'final',
'output' => ApprovalOutput::class,
],Endpoint definitions:
// BEFORE (v8)
'GET_STATUS' => [
'result' => OrderStatusResult::class,
'contextKeys' => ['totalAmount', 'currency'],
],
// AFTER (v9) — class form
'GET_STATUS' => [
'output' => OrderStatusOutput::class,
],
// AFTER (v9) — array form (replaces contextKeys)
'GET_PRICE' => [
'output' => ['totalAmount', 'currency'],
],Behavior arrays:
// BEFORE (v8)
behavior: [
'results' => [
'orderResult' => OrderResult::class,
],
],
// AFTER (v9)
behavior: [
'outputs' => [
'orderOutput' => OrderOutput::class,
],
],Forward endpoint config:
// BEFORE (v8)
'forward' => [
'PROVIDE_CARD' => [
'result' => CardSubmittedResult::class,
'contextKeys' => ['cardLast4'],
],
],
// AFTER (v9)
'forward' => [
'PROVIDE_CARD' => [
'output' => CardSubmittedOutput::class,
],
],New: State-Level Output (Any State)
In v8, result only worked on final states. In v9, output works on any state — the machine can expose different data depending on its current state:
'states' => [
'awaiting_vehicle' => [
'output' => [], // metadata only (no context data)
'on' => ['SUBMIT_VEHICLE' => 'pricing'],
],
'pricing' => [
'output' => ['installmentOptions', 'total'], // filtered context
'on' => ['SELECT_OPTION' => 'review'],
],
'review' => [
'output' => CustomerReviewOutput::class, // computed output
'on' => ['SUBMIT' => 'completed'],
],
'completed' => [
'type' => 'final',
'output' => OrderCompletedOutput::class,
],
],$machine->output() resolves the current state's output with hierarchical fallback:
- Current atomic state has
output? → use it - Parent compound state has
output? → use it - None →
toResponseArray()fallback
New: Output Validation
Defining output on invalid states throws InvalidOutputDefinitionException at definition time:
- Transient states (
@always) — never observed by consumers - Parallel region states — only the parallel state itself can define output
New: Consistent Response Envelope
All endpoints now return the same structure — availableEvents is never lost:
{
"data": {
"id": "01JARX...",
"machineId": "order_workflow",
"state": ["submitted"],
"availableEvents": ["APPROVE", "REJECT"],
"output": { "totalAmount": 100 },
"isProcessing": false
}
}Endpoints without a custom output use the current state's output (or toResponseArray() fallback). No need to define output on every endpoint — the state determines the response shape.
New: Graceful Lock Contention Handling
When a machine is processing an event (lock held), HTTP requests to the same machine no longer fail with a 500 error. Instead:
- GET endpoints return HTTP 200 with the last committed state +
isProcessing: true - POST/PUT/DELETE endpoints return HTTP 423 Locked with the last committed state +
isProcessing: true
The isProcessing field is present in every endpoint response:
false— normal path, event was processed, state is settledtrue— lock contention, returning last committed snapshot
This is especially useful when BroadcastStateAction triggers an immediate frontend status check — the GET request now returns the current state instead of crashing.
See Lock Contention Handling for details.
New: Consistent Behavior Resolution for Outputs
In v8, output behavior resolution was inconsistent across different entry points:
Machine::output()andresolveChildOutput()only supported class FQCN — inline keys from thebehavior['outputs']registry were not resolved, throwing aBindingResolutionExceptionMachineController::resolveAndRunOutput()supported both, but with its own duplicated logic
In v9, all output resolution uses a single unified method (MachineDefinition::resolveOutputKey()) with a consistent dispatch order: FQCN → registry → error. This is the same order used by getInvokableBehavior() for actions, guards, and calculators.
What this means in practice:
- Inline output keys now work everywhere —
Machine::output(), child machine@doneoutput, endpoint output, and forwarded endpoint output all resolve inline keys from thebehavior['outputs']registry - Invalid output keys now throw
BehaviorNotFoundExceptioninstead ofBindingResolutionException
Impact: If your code catches BindingResolutionException from output resolution (unlikely — this only happens with config typos), update the catch to BehaviorNotFoundException. No changes needed for valid configurations.
See Behavior Resolution for the full dispatch order documentation.
Migration Checklist
- Rename all
ResultBehaviorsubclasses to extendOutputBehavior - Rename class files:
{Name}Result→{Name}Output - In machine definitions:
'result' =>→'output' =>(states and endpoints) - In machine definitions:
'contextKeys' =>→'output' => [...](array form) - In behavior arrays:
'results' =>→'outputs' => - In PHP code:
$machine->result()→$machine->output() - In tests:
assertResult()→assertOutput() - In tests:
ChildMachineDoneEvent::result()→ChildMachineDoneEvent::output() - Update API consumers for new response envelope keys (
id,state,output,availableEvents) - Migrate parameterized behaviors:
'guard:arg1,arg2'→[[Guard::class, 'param' => value]](optional, deprecated syntax still works) - Update listener config:
Class::class => ['queue' => true]→[Class::class, '@queue' => true](required, old format removed)
New: Named Parameters for Behaviors
Behaviors now accept named parameters via array-tuple syntax. The old :arg1,arg2 colon syntax is deprecated (removed in v10).
Before (still works, deprecated):
'guards' => 'isAmountInRangeGuard:100,10000',
// Behavior receives untyped positional array
public function __invoke(ContextManager $ctx, ?array $arguments = null): bool {
return $ctx->get('amount') >= (int) $arguments[0]
&& $ctx->get('amount') <= (int) $arguments[1];
}After:
'guards' => [[IsAmountInRangeGuard::class, 'min' => 100, 'max' => 10000]],
// Behavior receives typed named parameters
public function __invoke(ContextManager $ctx, int $min, int $max): bool {
return $ctx->get('amount') >= $min
&& $ctx->get('amount') <= $max;
}Works with all behavior keys — guards, actions, calculators, entry/exit, outputs, listeners.
Output with named params (inner-array rule, same as guards/actions):
// Parameterized output — inner array
'output' => [[FormatOutput::class, 'format' => 'json']],
// Context key filter — plain array (unchanged)
'output' => ['orderId', 'totalAmount'],Migration pitfall: When migrating, update BOTH config AND behavior signature. If only config is changed, old ?array $arguments gets null — silent failure.
New: Listener Config Format (breaking)
The listener config format has changed. Class-as-key syntax is replaced with tuple syntax. @-prefixed keys are framework-reserved (never reach __invoke).
Before (no longer works):
'listen' => [
'entry' => [
SyncAction::class,
QueuedAction::class => ['queue' => true],
],
]After:
'listen' => [
'entry' => [
SyncAction::class,
[QueuedAction::class, '@queue' => true],
],
]With named params:
'listen' => [
'entry' => [
[AuditAction::class, 'verbose' => true, '@queue' => true],
],
]Migration steps:
- Find all
'listen'config blocks in your machine definitions. - Replace
ClassName::class => ['queue' => true]with[ClassName::class, '@queue' => true]. - Sync listeners (numeric key, no options) remain unchanged:
ClassName::class.
Exception Specialization (breaking)
v9 replaces generic PHP exceptions (InvalidArgumentException, RuntimeException) with domain-specific exception classes across the entire codebase. This enables targeted catch blocks and clearer error handling.
New Exception Classes
| Before (v8) | After (v9) | Thrown From |
|---|---|---|
InvalidArgumentException (config validation) | InvalidStateConfigException | StateConfigValidator, StateDefinition, MachineDefinition |
InvalidArgumentException (router config) | InvalidRouterConfigException | MachineRouter |
RuntimeException (no parent machine) | NoParentMachineException | InvokableBehavior |
InvalidArgumentException / RuntimeException (archive) | ArchiveException | MachineEventArchive, CompressionManager |
InvalidArgumentException (machine class) | InvalidMachineClassException | ChildMachineJob, SendToMachineJob |
InvalidArgumentException (job class) | InvalidJobClassException | ChildJobJob |
RuntimeException (behavior not faked) | BehaviorNotFakedException | Fakeable trait |
RuntimeException (no search paths) | MachineDiscoveryException | MachineConfigValidatorCommand |
InvalidArgumentException (timer) | InvalidTimerDefinitionException | Timer |
Renamed Exception Classes
| Before (v8) | After (v9) |
|---|---|
NoStateDefinitionFoundException | UndefinedTargetStateException |
Deleted Exception Classes
| Before (v8) | After (v9) |
|---|---|
InvalidFinalStateDefinitionException | Merged into InvalidStateConfigException (finalStateCannotHaveTransitions(), finalStateCannotHaveChildStates()) |
Extended Exception Classes
| Class | New Factory Methods |
|---|---|
InvalidEndpointDefinitionException | forwardConflictsWithEndpoint(), forwardConflictsWithBehaviorEvent(), duplicateForwardEvent() |
MachineDefinitionNotFoundException | failedToLoad() |
Migration Steps
If you catch any of the old generic exceptions for EventMachine errors, update your catch blocks:
// BEFORE (v8)
use InvalidArgumentException;
try {
StateConfigValidator::validate($config);
} catch (InvalidArgumentException $e) {
// caught ALL InvalidArgumentExceptions, not just config errors
}
// AFTER (v9)
use Tarfinlabs\EventMachine\Exceptions\InvalidStateConfigException;
try {
StateConfigValidator::validate($config);
} catch (InvalidStateConfigException $e) {
// catches only config validation errors
}// BEFORE (v8)
use RuntimeException;
try {
$context->sendToParent('CHILD_DONE');
} catch (RuntimeException $e) {
// caught ALL RuntimeExceptions
}
// AFTER (v9)
use Tarfinlabs\EventMachine\Exceptions\NoParentMachineException;
try {
$context->sendToParent('CHILD_DONE');
} catch (NoParentMachineException $e) {
// catches only the "no parent" case
}See Exceptions Reference for the full list of all exception classes.
Migration Checklist (updated)
Items 12–15 are new for the exception specialization:
- Update
catch (InvalidArgumentException)blocks that handle EventMachine config errors → specific exception classes (see table above) - Update
catch (RuntimeException)blocks that handle EventMachine runtime errors → specific exception classes - Rename
NoStateDefinitionFoundException→UndefinedTargetStateExceptionin any catch blocks or type hints - Remove
InvalidFinalStateDefinitionExceptionimports — nowInvalidStateConfigException
Typed Contracts — with → input, MachineInput/MachineOutput/MachineFailure
v9 introduces typed contracts for delegation boundaries. Machines and jobs can declare what data they expect (input), produce (output), and how their exceptions map to structured errors (failure).
with → input:
| Before (v8) | After (v9) | Effect |
|---|---|---|
'with' => ['orderId', 'amount'] | 'input' => ['orderId', 'amount'] | Untyped key mapping (renamed) |
'with' => ['amount' => 'totalAmount'] | 'input' => ['amount' => 'totalAmount'] | Key rename mapping (renamed) |
| N/A | 'input' => PaymentInput::class | Typed: auto-resolve from parent context |
| N/A | 'input' => fn(ContextManager $ctx) => new PaymentInput(...) | Typed: closure adapter |
New machine config keys:
MachineDefinition::define(config: [
'id' => 'payment',
'input' => PaymentInput::class, // declares expected input
'failure' => PaymentFailure::class, // maps exceptions to typed failures
'initial' => 'processing',
'context' => ['paymentId' => null],
]);Typed output on states via MachineOutput:
'completed' => [
'type' => 'final',
'output' => PaymentOutput::class, // extends MachineOutput — auto-resolved from context
],Typed output in parent @done/@fail actions:
'@done' => [
'target' => 'shipped',
'actions' => function (ContextManager $ctx, PaymentOutput $output): void {
$ctx->set('paymentId', $output->paymentId); // IDE autocomplete
},
],Job Interface Renames
| Before (v8) | After (v9) |
|---|---|
ReturnsResult | ReturnsOutput |
result() | output() |
ProvidesFailureContext | ProvidesFailure |
failureContext(Throwable): array | failure(Throwable): MachineFailure |
ForwardContext Removed
ForwardContext is removed. Forward endpoint OutputBehavior classes now inject child's MachineOutput by type-hint instead of accessing raw child internals. Forward endpoints without custom OutputBehavior use child's $machine->output() directly.
Machine::fake() Parameter Rename
| Before (v8) | After (v9) |
|---|---|
Machine::fake(result: [...]) | Machine::fake(output: [...]) |
| N/A | Machine::fake(output: new PaymentOutput(...)) |
New Base Classes
| Class | Purpose | Factory |
|---|---|---|
MachineInput | Parent → child data contract | fromContext(ContextManager): static |
MachineOutput | Child → parent data contract | fromContext(ContextManager): static |
MachineFailure | Exception → structured error | fromException(Throwable): static |
All three are abstract classes with readonly constructor properties and toArray() serialization. Subclass them with your domain-specific fields.
New Exceptions
| Exception | When |
|---|---|
MachineInputValidationException | MachineInput::fromContext() can't resolve a required constructor param |
MachineOutputResolutionException | MachineOutput::fromContext() can't resolve a required constructor param |
MachineOutputInjectionException | Forward endpoint OutputBehavior type-hints MachineOutput but child state has none |
MachineFailureResolutionException | MachineFailure::fromException() can't resolve a required constructor param |
Migration Checklist (typed contracts)
- Rename
'with' =>to'input' =>in all delegation configs (array format works as-is) - Rename
ReturnsResult→ReturnsOutput,result()→output()in job actors - Rename
ProvidesFailureContext→ProvidesFailure,failureContext()→failure()— return type changes fromarraytoMachineFailure - Replace
ForwardContexttype-hints in forward endpointOutputBehaviorclasses with child'sMachineOutputtype-hint - Optionally: define
MachineInput/MachineOutput/MachineFailuresubclasses for typed delegation contracts - Optionally: add
'input' => MyInput::classand'failure' => MyFailure::classto machine configs - Optionally: replace array
'output'on states withMachineOutputsubclasses - Run
composer quality
New Feature: Path Coverage Analysis
v9 adds automated path coverage analysis — static path enumeration, test-time tracking, coverage assertions, and artisan commands.
New Artisan Commands
| Command | Purpose |
|---|---|
machine:paths {machine} | Enumerate all paths through a machine definition (static analysis) |
machine:coverage {machine} | Report path coverage (reads test data, supports --min for CI gates) |
New Assertions
// Assert all enumerated paths are covered by tests
FindeksMachine::assertAllPathsCovered();
// Assert minimum coverage threshold
FindeksMachine::assertPathCoverage(minimum: 90.0);New Trait: TracksPathCoverage
Add to your test suite for automatic path coverage tracking. Works with PHPUnit, Pest, and parallel runners (Paratest).
// Pest:
uses(TracksPathCoverage::class)->in('Feature', 'Unit');
// PHPUnit:
abstract class TestCase extends BaseTestCase {
use TracksPathCoverage;
}The trait automatically enables tracking, cleans stale data from previous runs, and exports coverage when the process exits. Each parallel worker writes a separate file; the machine:coverage command merges them.
Child Machine Visibility
machine:paths shows child machine and job class names on invoke state steps, detailed delegation info in stats, and warns about unhandled child outcomes:
Child machines: 1
processing → PaymentMachine (async, queue: payments)
#1 → idle
→ [START] processing (PaymentMachine)
→ [@done.approved] completed
⚠ UNHANDLED CHILD OUTCOMES:
processing → PaymentMachine
Child final states: approved, rejected
Parent handles: @done.approved
Unhandled: rejectedEach machine is analyzed independently (compositional verification). Run machine:paths on child machines separately to see their internal paths.
Large Machines
Machines with mutual state cycles (e.g., approved ↔ rejected) can generate thousands of valid paths. Use --max-paths to control enumeration:
php artisan machine:paths "App\Machines\LargeMachine" --max-paths=5000Default limit is 1000. The command warns when the limit is reached.
Path Types
Enumerated paths are classified by type: HAPPY, FAIL, TIMEOUT, LOOP, GUARD_BLOCK, DEAD_END, and — since the parallel path-analysis work — TRUNCATED, plus REGION_EXIT and REGION_DEFERRED for paths inside a parallel region.
See Transitions & Paths — Path Coverage Analysis for full documentation.
New: Machine Query Builder
New fluent API for finding machine instances by state. No breaking changes — purely additive.
Before (direct table query):
$machineIds = MachineCurrentState::query()
->where('machine_class', OrderMachine::class)
->where('state_id', 'order.checkout.awaiting_payment')
->pluck('root_event_id');
foreach ($machineIds as $id) {
$machine = OrderMachine::create(state: $id);
}After (Machine::query()):
$results = OrderMachine::query()
->inState('awaiting_payment') // leaf match — no full ID needed
->latest()
->paginate(20);
// Lightweight results with lazy restore
$results->first()->machineId; // root_event_id
$results->first()->machine(); // full Machine instance (lazy)Key features: leaf/exact/parent/wildcard state matching, active()/notInFinalState() helpers, inAllStates() for parallel AND queries, automatic parallel state deduplication, LengthAwarePaginator support.
See Querying Machines for full documentation.
Bug Fixes in 9.0
These production bugs were discovered and fixed during QA testing with real Horizon:
SendToMachineJobevent retry —NoTransitionDefinitionFoundExceptionpreviously logged a warning and silently dropped the event. Now usesrelease(2)to retry, with$tries=25and$maxExceptions=3to handle lock contention. Events are no longer lost when the target machine hasn't yet reached the correct state.MachineOutputserialization —resolveChildOutput()can return aMachineOutputinstance, butChildMachineCompletionJobexpects?array. Fixed inChildMachineJob,MachineController,MachineDefinition::tryForwardEventToChild(), andChildMachineCompletionJob::propagateChainCompletion().- Deep delegation failure propagation —
ChildMachineCompletionJob::propagateChainCompletion()always passedsuccess: trueto the grandparent, even when the middle machine failed. Now correctly propagates the failure flag. - Archived parent auto-restore —
ChildMachineCompletionJobcaught allThrowableand silently discarded when the parent was archived. Now catchesRestoringStateExceptionspecifically and attempts archive auto-restore before routing@done/@fail. - Job actor test mode —
handleJobInvoke()andhandleAsyncMachineInvoke()dispatched real jobs even in test mode (shouldPersist=false), causing infinite loops with sync queue when entering chained job states. Now skips dispatch in test mode — usesimulateChildDone()/simulateChildFail()to step through job states.
9.3.0 — Explicit Timer Registration
Breaking change: Timer sweep auto-discovery removed. Machines with @after or @every timers must register explicitly.
Why: The auto-discovery scanned every PHP file in app/ using PhpParser on every application boot. In large projects (4000+ files), this added ~3.7 seconds per boot — multiplied by every PHPUnit test.
Before (v9.2):
// Nothing needed — timers auto-discovered (3.7s per boot without cache)
// Production required: php artisan machine:cacheAfter (v9.3):
// routes/console.php
use Tarfinlabs\EventMachine\Scheduling\MachineTimer;
MachineTimer::register(OrderMachine::class); // everyMinute (default)
MachineTimer::register(BillingMachine::class)
->everyFiveMinutes(); // custom frequencyMigration steps:
- Search your machine definitions for
'after'and'every'keys — these are your timer machines - Add one
MachineTimer::register(YourMachine::class)line per timer machine inroutes/console.php - Remove
php artisan machine:cachefrom CI/CD pipelines and deploy scripts - Delete
bootstrap/cache/machines.phpfrom production servers (if present)
Removed:
| Component | Replacement |
|---|---|
machine:cache command | No longer needed |
machine:clear command | No longer needed |
MachineDiscovery class | Not needed — explicit registration |
TimerResolution enum | Frequency set via fluent API |
config('machine.timers.resolution') | MachineTimer::register()->everyMinute() |
From 7.x to 8.0
v8 is about event preservation and testing maturity. The single breaking change aligns @always transition behavior with XState v5 and the W3C SCXML spec. The rest of the release series adds endpoint filtering, EventBuilder, bulk faking, computed context, auto-generated event types, and numerous bug fixes for parallel state + delegation interactions.
8.0.0 — Event Preservation Through @always
Breaking change: Behaviors (actions, guards, calculators) on @always transitions now receive the original triggering event instead of the synthetic @always event.
| Aspect | v7 | v8 |
|---|---|---|
$event->type in @always behavior | '@always' | Original event type |
$event->payload in @always behavior | null | Original event payload |
$event->actor() in @always behavior | Derived from context | Derived from original event |
Who is affected? Only if your behaviors on @always transitions check $event->type === '@always' or rely on $event->payload being null. This is uncommon — most @always behaviors use only ContextManager and ignore the event.
Before (v7):
// Action on @always transition
class MyAction extends ActionBehavior
{
public function __invoke(ContextManager $context, EventBehavior $event): void
{
$event->type; // '@always'
$event->payload; // null — payload lost!
}
}After (v8):
// Same action, same @always transition — now receives the real event
class MyAction extends ActionBehavior
{
public function __invoke(ContextManager $context, EventBehavior $event): void
{
$event->type; // 'ORDER_SUBMITTED' (the original event)
$event->payload; // ['tckn' => '123...'] (preserved!)
}
}Migration steps:
- Update
composer.json:"tarfin-labs/event-machine": "^8.0" - Search for behaviors on
@alwaystransitions that useEventBehavior— if they check$event->type === '@always', remove the check; if they rely on$event->payloadbeingnull, update to handle the real payload - Run your tests
New feature: Raise actor auto-propagation — Raised events automatically inherit actor from the triggering event when not explicitly set:
// Before (v7) — manual boilerplate
$this->raise(new ApprovedEvent(
payload: $data,
actor: $event->actor($context),
));
// After (v8) — auto-inherited
$this->raise(new ApprovedEvent(
payload: $data,
));New feature: Endpoint filtering (only/except) — MachineRouter::register() accepts only and except to split endpoints across middleware groups:
MachineRouter::register(CarSalesMachine::class, [
'prefix' => 'car-sales',
'only' => [ConsentGrantedEvent::class, PersonalInfoSubmittedEvent::class],
'name' => 'car-sales.public',
]);Stricter validation: machineIdFor/modelFor — Router now validates that referenced event types exist in the registered endpoint set. Previously silently ignored.
8.1.0 — EventBuilder + HasBuilder
Purpose-built test data builders for complex event payloads:
OrderSubmittedEvent::builder()
->withOrderItems(3)
->withFarmerPaymentDate()
->make();EventBuilderabstract base class with::new(),state(),make(),raw()HasBuildertrait addsEvent::builder()to event classes (like Laravel'sHasFactory)
8.2.0 — Endpoint Filtering
only/except options on MachineRouter::register() for splitting endpoints across route groups. See 8.0.0 above.
8.2.1 — Machine Delegation Fix in Parallel Regions
Fixed child machines configured via the machine: key never being invoked in 7 different state entry paths — most critically parallel region initial states. Centralized entry protocol into enterState() and enterStateInParallelRegion() (-113 lines).
8.2.2 — Parallel + Delegation Follow-Up
Three additional fixes: forward events not routed in parallel state, event history snapshots corrupted in parallel context, and ChildMachineCompletionJob silently skipped in parallel context.
8.2.3 — Job Actor Dependency Injection
Fixed ChildJobJob bypassing Laravel's service container — job actors with type-hinted handle() parameters now resolve correctly via app()->call().
8.2.4 — Event Queue After Child @done/@fail/@timeout
Fixed raised events and @always transitions not being processed after child completion transitions.
8.3.0 — simulateChildDone/Fail/Timeout for Job Actors
simulateChildDone(), simulateChildFail(), and simulateChildTimeout() now work with both machine and job delegation.
8.4.0 — Bulk Faking and startingAt()
Three testing DX improvements:
fakingAllActions()/fakingAllGuards()/fakingAllBehaviors()— fake all class-based behaviors in one call withexcept:parameterguards:parameter onwithContext()/create()— set guard fakes before machine initializationstartingAt()— create machine at any state without running lifecycle
OrderMachine::startingAt('processing', context: ['orderId' => 1])
->fakingAllActions(except: [CriticalAction::class])
->send('COMPLETE')
->assertState('completed');8.4.1 — Pre-Init Action Faking
Added faking: parameter to withContext(), create(), and startingAt() for spying actions before machine initialization.
8.4.2 — startingAt() Timer Support
Fixed startingAt() not calling trackStateEntry(), which caused advanceTimers() to silently do nothing.
8.5.0 — Testing Entry Point Simplification
Machine::test() and Machine::startingAt() are now the only entry points for class-based machine testing:
| Before | After |
|---|---|
TestMachine::create(MyMachine::class) | MyMachine::test() |
TestMachine::withContext(MyMachine::class, [...]) | MyMachine::test(context: [...]) |
TestMachine::startingAt(MyMachine::class, 'state', [...]) | MyMachine::startingAt('state', context: [...]) |
Behavior change: Machine::test(context: [...]) now merges context before initialization — entry actions see injected values.
Also added assertNotDispatchedTo() and two new documentation pages (Real Infrastructure Testing, Testing Troubleshooting).
8.5.1 — raise() After Compound/Parallel @done
Fixed raise() and @always not processed after processCompoundOnDone(), processNestedParallelCompletion(), and exitParallelStateAndTransitionToTarget().
8.5.2 — Fire-and-Forget Post-Entry Fix
Extended the processPostEntryTransitions fix to fire-and-forget code paths in handleJobInvoke(), handleAsyncMachineInvoke(), and handleFakedMachineInvoke().
8.5.3 — Centralize processPostEntryTransitions
Architectural fix eliminating an entire bug class. enterState() now internally calls processPostEntryTransitions() — callers no longer need to remember to call it. The same "forgot to call processPostEntryTransitions()" bug appeared 7 times across 8.2.4, 8.5.1, 8.5.2, and 8.5.3. Now impossible.
Also added assertRaised()/assertNotRaised()/assertRaisedCount()/assertNothingRaised() for isolated action testing.
If you subclass MachineDefinition
If you call processPostEntryTransitions() directly, remove those calls. enterState() handles it automatically via the processPostEntry parameter (default true).
8.5.4 — ResultBehavior Event Fix
Fixed ResultBehavior receiving internal event data (NULL payload) instead of the original triggering event. Machine::result() and MachineController::resolveAndRunResult() now use $state->triggeringEvent.
8.6.0 — Computed Context in API Responses
Custom context classes can expose computed values in endpoint responses via computedContext():
class OrderContext extends ContextManager
{
public function __construct(
public array $items = [],
public float $total = 0.0,
) {
parent::__construct();
}
protected function computedContext(): array
{
return [
'itemCount' => count($this->items),
'isEmpty' => empty($this->items),
];
}
}Computed values appear in endpoint responses and State::toArray() but are not persisted to the database. Existing context classes without computedContext() are unaffected.
8.6.1 — ValidationGuardBehavior in Parallel States
Fixed ValidationGuardBehavior failure inside parallel state regions throwing NoTransitionDefinitionFoundException instead of returning a 422 validation error.
8.6.2 — Concurrent State Mutation Protection
Major QA infrastructure overhaul with 16 new real-Horizon tests and 5 concurrency bug fixes:
- Always-on lock for async queues —
Machine::send()acquires a lock for all persisted machines when the queue driver is async - Deep delegation chain propagation —
ChildMachineCompletionJobpropagates completion through multi-level chains (Parent → Child → Grandchild) SendToMachineJobretry — catches lock contention and usesrelease(1)for graceful retryListenerJoblock protection — concurrent listeners no longer overwrite each other's contextChildMachineJobduplicate prevention —lockForUpdate()on tracking record
8.6.3 — SCXML Compliance and Test Hardening
75+ new test files from analysis of 12 state machine implementations and 210 W3C SCXML IRP tests. Four bug fixes:
- Action ordering corrected to
exit → transition → entry(wastransition → exit → entry) - Targetless transitions no longer fire exit/entry actions (internal transition semantics)
- Guard context mutation leak prevented via snapshot/restore
- Raised events processed before delegation (SCXML invoker-05)
Also added cross-region transition rejection validation.
8.6.4 — machineId() After Restore
Fixed $context->machineId() returning null after state restore. restoreStateFromRootEventId() now calls setMachineIdentity() on every restore.
8.7.0 — Auto-Generated Event Behavior Types
EventBehavior::getType() is no longer abstract — it auto-derives the event type from the class name:
// Before — boilerplate
class OrderSubmittedEvent extends EventBehavior
{
public static function getType(): string
{
return 'ORDER_SUBMITTED';
}
}
// After — auto-generated from class name
class OrderSubmittedEvent extends EventBehavior
{
// getType() returns 'ORDER_SUBMITTED' automatically
}Existing getType() overrides continue to work. You can optionally remove them when the return value matches the convention.
8.7.1 — GET Endpoint Query Parameter Validation
Fixed GET endpoint query parameters silently bypassing EventBehavior validation. MachineController::resolveRequestData() now wraps GET query params into the payload key.
8.7.2 — Parallel State Guard Failure
Fixed regular GuardBehavior failure in parallel states throwing NoTransitionDefinitionFoundException. Now correctly records TRANSITION_FAIL and stays in the current state (matching non-parallel behavior).
8.7.3 — Parallel Internal Event Naming
Fixed exit/entry events in parallel region internal transitions using the parallel ancestor's route instead of the actual atomic state's route.
Migration Checklist (v7 → v8)
- Update
composer.json:"tarfin-labs/event-machine": "^8.0" - Search for behaviors on
@alwaystransitions that check$event->type === '@always'or rely on null payload — update them - Run
composer quality
From 6.x to 7.0
v7 is the actor model release — machines can delegate to child machines, communicate across instances, react to time, and run on schedules. This is a major feature release with no breaking changes to existing machines. All existing code continues to work unchanged.
7.0.0 — State Machines That Compose
New feature: Machine delegation — States can launch child machines synchronously or asynchronously:
'processing_payment' => [
'machine' => PaymentMachine::class,
'with' => ['orderId', 'totalAmount'],
'@done' => 'shipping',
'@fail' => 'payment_failed',
'@timeout' => ['after' => 300, 'target' => 'payment_timed_out'],
'queue' => 'payments',
],New feature: Cross-machine communication — Five methods for inter-machine messaging:
| Method | Direction | Mode |
|---|---|---|
sendTo() | → Any machine | Sync |
dispatchTo() | → Any machine | Async |
sendToParent() | → Parent | Sync |
dispatchToParent() | → Parent | Async |
raise() | → Self | Sync |
New feature: Time-based events — after (one-shot) and every (recurring) timers:
'awaiting_payment' => [
'on' => [
'PAY' => 'processing',
'ORDER_EXPIRED' => ['target' => 'cancelled', 'after' => Timer::days(7)],
'REMINDER' => ['actions' => 'sendReminderAction', 'every' => Timer::days(1)],
],
],New feature: Scheduled events — Cron-based batch operations:
use Tarfinlabs\EventMachine\Scheduling\MachineScheduler;
MachineScheduler::register(ApplicationMachine::class, 'CHECK_EXPIRY')
->dailyAt('00:10')
->onOneServer();New feature: Machine faking — Short-circuit child machines in tests:
use Tarfinlabs\EventMachine\Actor\Machine;
PaymentMachine::fake(result: ['paymentId' => 'pay_123']);
$machine = OrderWorkflowMachine::create();
$machine->send(['type' => 'START']);
PaymentMachine::assertInvoked();
PaymentMachine::assertInvokedWith(['orderId' => 'ORD-1']);
Machine::resetMachineFakes();New feature: Machine identity — $context->machineId() and $context->parentMachineId()
New feature: Infinite loop protection — Configurable max_transition_depth (default 100)
New database tables — Three new tables required:
| Table | Purpose |
|---|---|
machine_children | Async child machine tracking |
machine_current_states | Current state per instance (timers, schedules) |
machine_timer_fires | Timer dedup and recurring fire tracking |
New artisan commands:
| Command | Purpose |
|---|---|
machine:process-timers | Sweep timer events (auto-registered) |
machine:process-scheduled | Process scheduled events |
machine:timer-status | Display timer status |
machine:cache | Cache machine discovery for production |
machine:clear | Clear machine discovery cache |
Migration steps:
composer require tarfinlabs/event-machine:^7.0
php artisan vendor:publish --tag=machine-migrations
php artisan migrate7.1.0 — Fire-and-Forget Machine Delegation
States can spawn child machines in the background without tracking lifecycle. Omit @done on a machine + queue state:
'prevented' => [
'machine' => TurmobVerificationMachine::class,
'with' => ['tckn'],
'queue' => 'verifications',
// No @done → fire-and-forget
'on' => ['RETRY' => 'retrying'],
],Three patterns: stay in state, spawn and move on (with @always), spawn and move on (with target).
7.2.0 — Forward-Aware Endpoints
Forward events are now auto-discovered from child machine definitions — no duplicate declarations needed.
Breaking Change
Forward events that also appear in parent's endpoints or behavior.events are now rejected at parse time. Remove forwarded events from behavior.events and endpoints — the forward key is the single source of truth.
Before:
use Tarfinlabs\EventMachine\Actor\Machine;
use Tarfinlabs\EventMachine\Definition\MachineDefinition;
class PaymentFlowMachine extends Machine
{
public static function definition(): MachineDefinition
{
return MachineDefinition::define(
config: [
'id' => 'payment_flow',
'initial' => 'collecting',
'context' => ['orderId' => null],
'states' => [
'collecting' => [
'on' => ['START' => 'processing'],
],
'processing' => [
'machine' => PaymentChildMachine::class,
'queue' => 'payments',
'forward' => ['PROVIDE_CARD'],
'@done' => 'completed',
],
'completed' => ['type' => 'final'],
],
],
behavior: [
'events' => [
'START' => StartEvent::class,
'PROVIDE_CARD' => ProvideCardEvent::class, // REMOVE
],
],
);
}
}After:
use Tarfinlabs\EventMachine\Actor\Machine;
use Tarfinlabs\EventMachine\Definition\MachineDefinition;
class PaymentFlowMachine extends Machine
{
public static function definition(): MachineDefinition
{
return MachineDefinition::define(
config: [
'id' => 'payment_flow',
'initial' => 'collecting',
'context' => ['orderId' => null],
'states' => [
'collecting' => [
'on' => ['START' => 'processing'],
],
'processing' => [
'machine' => PaymentChildMachine::class,
'queue' => 'payments',
'forward' => ['PROVIDE_CARD'],
'@done' => 'completed',
],
'completed' => ['type' => 'final'],
],
],
behavior: [
'events' => [
'START' => StartEvent::class,
// PROVIDE_CARD removed — child owns it
],
],
);
}
}Also added available_events introspection, ForwardContext injection, and 5 TestMachine assertion methods.
7.3.0 — @done.{state} Per-Final-State Routing
Route parent based on which final state the child reached:
'verifying' => [
'machine' => VerificationMachine::class,
'@done.approved' => 'processing',
'@done.rejected' => 'declined',
'@done.expired' => 'timed_out',
'@fail' => 'system_error',
],7.4.0 — TestMachine v2 API
15 new fluent test methods for child delegation, async simulation, and cross-machine communication:
OrderMachine::test()
->fakingChild(PaymentMachine::class, result: ['id' => 'pay_1'], finalState: 'approved')
->send('PLACE_ORDER')
->assertState('completed')
->assertChildInvoked(PaymentMachine::class)
->assertRoutedViaDoneState('approved');7.4.1
- Fixed TestMachine assertions using Pest-only
expect()— now usesPHPUnit\Framework\Assertfor compatibility with both Pest and PHPUnit
7.5.0 — Fakeable Machine::create()
Machine::fake() now intercepts Machine::create() for controller test isolation:
CarSalesMachine::fake();
$this->postJson("/consent/{$hash}/approve")->assertOk();
CarSalesMachine::assertCreated();
CarSalesMachine::assertSent('CONSENT_GRANTED');Also added InteractsWithMachines trait for automatic test cleanup.
7.6.0 — In-Memory Timer Testing
advanceTimers() now works without database persistence. Also added ChildMachineDoneEvent::forTesting() and ChildMachineFailEvent::forTesting() factories.
7.6.1
- Fixed
CarbonInterfacetype-hints — usesCarbonInterfaceinstead ofCarbonfornow()compatibility
7.6.2
- Fixed targetless transitions accepting
''and[]in addition tonull
7.7.0 — Exception Metadata in @fail Handlers
ProvidesFailureContext contract for structured error data in @fail guards:
use Tarfinlabs\EventMachine\Contracts\ProvidesFailureContext;
class ConfirmFindeksPinJob implements ProvidesFailureContext
{
public static function failureContext(\Throwable $exception): array
{
if ($exception instanceof FindeksException) {
return [
'errorCode' => $exception->getFindeksErrorCode(),
'retryable' => $exception->isRetryable(),
];
}
return ['errorCode' => 'UNKNOWN'];
}
}7.7.1
- Documentation updates for
ProvidesFailureContext
7.8.0 — Machine-Level Entry & Exit Actions
Root-level entry/exit actions now execute (previously parsed but never run):
MachineDefinition::define(
config: [
'id' => 'order',
'initial' => 'pending',
'entry' => 'initializeTrackingAction', // runs once on start
'exit' => 'finalCleanupAction', // runs once on final state
'states' => [...],
],
);7.9.0 — State Change Listeners
Cross-cutting actions on every state change via listen config:
'listen' => [
'entry' => BroadcastStateAction::class,
'exit' => AuditLogAction::class,
'transition' => FullAuditTrailAction::class,
],Supports sync and queued (['queue' => true]) listeners. Transient states with @always are automatically skipped.
7.9.1
- Test coverage and documentation for listener + child delegation isolation
7.9.2 — Machine::result() Parameter Injection
Fixed Machine::result() using positional arguments instead of type-hint based parameter injection.
Migration Steps (v6 → v7)
composer require tarfinlabs/event-machine:^7.0php artisan vendor:publish --tag=machine-migrations && php artisan migrate- Start using new features when ready — no existing code needs to change
From 5.x to 6.0
v6 introduces a comprehensive testability layer that makes state machine testing a first-class citizen. Three breaking changes to behavior resolution — most applications require no code changes.
6.0.0 — Testability Layer
Breaking change 1: Behavior resolution via container — Behaviors are now resolved through App::make() instead of new $class(). This enables constructor dependency injection.
Before:
// MachineDefinition::getInvokableBehavior()
return new $behaviorDefinition($this->eventQueue);After:
return App::make($behaviorDefinition, ['eventQueue' => $this->eventQueue]);Action required only if you override InvokableBehavior::__construct() with non-injectable parameters (plain string, int, array without defaults). Register a container binding:
$this->app->when(MyBehavior::class)->needs('$prefix')->give('my_prefix');Breaking change 2: InvokableBehavior::run() always uses container (was new static() for non-faked behaviors).
Breaking change 3: Fakeable::fake() uses App::bind() with Closure. resetFakes() now uses app()->offsetUnset().
New testing features:
| Feature | Description |
|---|---|
Machine::test() | Fluent test wrapper with 21+ assertion methods |
State::forTesting() | Lightweight state factory for unit tests |
runWithState() | Isolated testing with engine-identical DI |
EventBehavior::forTesting() | Test factory for event construction |
| Constructor DI | Behaviors can inject service dependencies |
spy(), allowToRun(), mayReturn() | Enhanced fakeable API |
Migration steps:
composer require tarfinlabs/event-machine:^6.0- Search for behaviors overriding
__construct()with non-injectable parameters — register bindings - Ensure
resetAllFakes()is called inafterEachfor test fake cleanup
6.1.0 — HTTP Endpoints
Declarative endpoint layer — define endpoints in machine config, MachineRouter generates Laravel routes:
MachineRouter::register(OrderMachine::class, [
'prefix' => 'orders',
'model' => Order::class,
'attribute' => 'machine',
'create' => true,
'machineIdFor' => ['CANCEL'],
]);Four routing patterns: stateless, machineId-bound, model-bound, and hybrid. State now implements JsonSerializable.
6.2.0 — XState Export & Stately Studio Integration
New machine:xstate command replaces the old PlantUML generator:
php artisan machine:xstate "App\Machines\OrderMachine" --stdout
php artisan machine:xstate "App\Machines\OrderMachine" --format=jsExports states, transitions, guards, actions, calculators, parallel/final states, context, and event payload schemas.
6.3.0 — Inline Behavior Faking
Inline closure behaviors can now be faked during tests:
OrderMachine::test()
->faking(['hasItemsGuard' => false])
->assertGuarded('SUBMIT');InlineBehaviorFake intercepts closures at their invocation site in the engine.
6.4.0 — Explicit Model Routing & Endpoint DX
Breaking Change
Model-bound routing is no longer implicit. You must declare which events use model binding via modelFor:
// Before (v6.1–v6.3): implicit
MachineRouter::register(OrderMachine::class, [
'model' => Order::class,
]);
// After (v6.4): explicit
MachineRouter::register(OrderMachine::class, [
'model' => Order::class,
'modelFor' => ['SUBMIT', 'APPROVE'],
]);Also added list syntax for endpoints, _EVENT suffix auto-stripping in URIs, and event class keys in router options.
Migration Checklist (v5 → v6)
composer require tarfinlabs/event-machine:^6.0- Check custom
__construct()overrides on behaviors — register bindings for non-injectable parameters - Add
resetAllFakes()to test cleanup - Optionally adopt
Machine::test()fluent API
From 4.x to 5.0
v5 brings true parallel execution — region entry actions run as concurrent Laravel queue jobs across multiple workers.
5.0.0 — True Parallel Dispatch
Opt-in concurrent execution via ParallelRegionJob queue jobs. Disabled by default — existing parallel state machines work unchanged.
// config/machine.php
return [
'parallel_dispatch' => [
'enabled' => env('MACHINE_PARALLEL_DISPATCH_ENABLED', false),
'queue' => env('MACHINE_PARALLEL_DISPATCH_QUEUE', null),
'lock_timeout' => env('MACHINE_PARALLEL_DISPATCH_LOCK_TIMEOUT', 30),
'lock_ttl' => env('MACHINE_PARALLEL_DISPATCH_LOCK_TTL', 60),
'job_timeout' => env('MACHINE_PARALLEL_DISPATCH_JOB_TIMEOUT', 300),
'job_tries' => env('MACHINE_PARALLEL_DISPATCH_JOB_TRIES', 3),
'job_backoff' => env('MACHINE_PARALLEL_DISPATCH_JOB_BACKOFF', 30),
'region_timeout' => env('MACHINE_PARALLEL_DISPATCH_REGION_TIMEOUT', 0),
],
];Region timeout — configurable watchdog for stuck parallel states. Seven new internal events for observability (PARALLEL_REGION_ENTER, PARALLEL_CONTEXT_CONFLICT, PARALLEL_DONE, etc.).
New machine_locks table — database-based locking for parallel dispatch.
Migration steps:
composer update tarfinlabs/event-machine:^5.0
php artisan vendor:publish --tag=machine-migrations
php artisan migrate5.1.0 — Conditional @done/@fail with Guards
@done and @fail transitions now support conditional branches:
'@done' => [
['target' => 'approved', 'guards' => IsAllSucceededGuard::class],
['target' => 'manual_review'], // fallback
],5.1.1
- Fixed root-level
onevents not working during parallel state (parallel escape transitions) - Fixed
selectTransitionsdeduplication for ancestor-level handlers
5.1.2
- Fixed targetless
@done/@failbranch actions being silently skipped - Fixed nested parallel exit actions not running on
@done - Fixed region ID prefix collision (
region_amatchingregion_ab) - Fixed missing
TRANSITION_START/TRANSITION_FINISHevents for parallel @done/@fail
From 3.x to 4.0
v4 adds parallel states — multiple concurrent regions with full lifecycle management.
4.0.0 — Parallel States
Breaking changes:
- Dropped PHP 8.2 support — requires PHP 8.3+ (Pest v4 dependency)
- Dropped Laravel 10 support — requires Laravel 11+
- Dropped Orchestra Testbench ^8.x — requires ^9.0+
New features:
- Parallel states —
'type' => 'parallel'with multiple concurrent regions onDoneauto-transitions — fire when all regions reach final states- Compound state
onDone— XState-compatibleonDonefor compound states within parallel regions - Multi-value state support —
matches(),matchesAll(),isInParallelState() - DocTest integration — documentation code blocks tested automatically
Migration steps:
- Upgrade to PHP 8.3+ and Laravel 11+
composer require tarfinlabs/event-machine:^4.0- Review any custom
StateConfigValidatorusage — parallel state validation now usesInvalidParallelStateDefinitionException
4.0.1
- Fixed
@alwaysguard exception in parallel states — machine now correctly stays in current state when guard evaluates tofalse
4.0.2
- Fixed
areAllRegionsFinal()nested final detection — only direct children of a parallel region count as region-final - Added compound state
onDonesupport with recursive chaining
From 2.x to 3.0
v3 introduces parameter injection by type-hint, custom context classes, calculators, and the event archival system.
3.0.0 — Type-Hinted Behaviors and Event Archival
Breaking change 1: Behavior parameter injection — Parameters are now injected based on type hints, not position.
Before (v2.x):
··· 1 hidden line
class MyAction extends ActionBehavior
{
public function __invoke($context, $event): void
{
// Parameters were positional
}
}After (v3.x):
··· 3 hidden lines
class MyAction extends ActionBehavior
{
public function __invoke(ContextManager $context, EventBehavior $event): void
{
// Type-hinted parameters are injected
}
}Breaking change 2: ContextManager access — Direct array access deprecated.
Before (v2.x):
$context->data['key'] = 'value';After (v3.x):
$context->set('key', 'value');
// or
$context->key = 'value';Breaking change 3: State matching — Use matches() instead of direct comparison.
Before (v2.x):
$machine->state->value === 'pending';After (v3.x):
$machine->state->matches('pending');Breaking change 4: PHP 8.2+ required (upgraded from 8.1).
New features:
- Calculators — New behavior type that runs before guards for context pre-computation
- Event class keys — Use event classes directly as transition keys (
SubmitEvent::class => [...]) - Custom context classes —
ContextManagersubclasses with typed properties and validation - Event archival —
ArchiveServicewith compression, fan-out processing, and auto-restore - Config validator command —
php artisan machine:validate - PHPStan level 5 compliance
Migration steps:
composer require tarfinlabs/event-machine:^3.0php artisan migrate(new archive tables)- Update all behavior
__invoke()signatures to use type hints - Replace
$context->data['key']with$context->get('key')or$context->key - Replace
$state->value === 'state'with$state->matches('state') - Move context modifications from guards to calculators
3.0.1
- Fixed slow archival queries on large tables (57GB+) — replaced
NOT EXISTSsubquery withGROUP BY + HAVINGpattern (400+ seconds → ~100ms)
3.0.2
- Fixed config value type casting in
ArchiveService—level,days_inactive,restore_cooldown_hoursnow properly cast toint
From 1.x to 2.0
v2 introduces calculator behaviors, inline behavior testing, static context validation, reset-all-fakes, and machine config validation.
2.0.0 — Calculators and Config Validation
Breaking change: State value format — State values are now arrays containing the full path.
Before (v1.x):
$machine->state->value; // 'pending'After (v2.x):
$machine->state->value; // ['machine.pending']Breaking change: Machine creation — Use the static create() method.
Before (v1.x):
$machine = new OrderMachine();
$machine->start();After (v2.x):
$machine = OrderMachine::create();Breaking change: Event sending — Events use array format.
Before (v1.x):
$machine->dispatch('SUBMIT', ['key' => 'value']);After (v2.x):
$machine->send([
'type' => 'SUBMIT',
'payload' => ['key' => 'value'],
]);New features:
- Calculator behaviors — Pre-compute values before guards
- Inline behavior testing — Test inline closures from machine definitions
- Static context validation — Context validation methods converted to static
- Reset all fakes —
resetAllFakes()for test cleanup - Config validation —
StateConfigValidatorfor definition-time checks
2.0.1
- Added support for status events (
@done,@fail) in root-level config keys
2.1.0
- Added
machine:validateartisan command - Added tests for calculator execution in guarded transitions
- Added Laravel 12.x compatibility
2.1.1
- Fixed
Fakeabletrait issue with mock registration
2.1.2
- Added
InteractsWithInputtrait forEventBehavior
1.x — Initial Release Series
The foundation of EventMachine — event-driven state machines for Laravel with persistence, behaviors, and guards.
1.0.0 — First Release
The initial release of EventMachine, providing core state machine functionality:
- Machine definitions with states and transitions
- Action, guard, and event behaviors
- Event persistence via
machine_eventstable - State restoration from event history
1.0.1
- Fixed scenario bugs in state machine execution
1.1.0
- Removed guard start events from event history (noise reduction)
1.2.0
- Incremental context storage — reduced
machine_eventscontext field size by storing only changes - Behavior dependency injection — behaviors receive injected parameters
- Configurable persistence —
should_persistoption to disable logging for non-critical machines - Mockable actions — actions can be mocked in tests
stopOnFirstFailure— validation guard improvement- State diagram generation — automatic state machine diagram creation
1.3.0
- Added Laravel 11 support
1.4.0
- Fixed
MachineCastsetmethod to handle uninitialized machines
1.5.0
- Improved type resolution in
InvokableBehaviorfor parameter injection
1.6.0
- Added
machines()method on Eloquent models viaHasMachinestrait — set machines on a model without individual casts
1.7.0
- Added
Fakeabletrait for invokable behaviors —fake(),spy(),shouldReturn()
Getting Help
If you encounter issues during upgrade:
- Check the GitHub Issues
- Review the Release Notes
- Open a new issue with your upgrade scenario