Determinism rules
handle() is replayed from the start on every resume, so it must be deterministic: given the
same recorded history, it must reach the same operations in the same order. The engine reuses
recorded results for completed operations and only executes the next un-run one.
Do
- ✅ Call actions, child workflows, signals, and parallel blocks through the DSL — their results are recorded and reused on replay.
- ✅ Wrap any nondeterminism in
sideEffect():now(), random values, UUIDs, tokens, external reads whose result you want frozen for the run. - ✅ Derive control flow from recorded values (action results, signal payloads, side effects).
Don't
-
❌ Branch on ambient state that can change between replays — wall-clock time,
rand(), direct DB/HTTP reads — outside asideEffect(). -
❌ Reorder, add, or remove already-recorded steps in a version that has in-flight runs (see Versioning).
-
❌ Drive another workflow from inside
handle()withSagaFlow::create(...)->runSync()or->run(). Those calls take no ordinal, so nothing recognizes the run one of them started, and the next replay starts another.child()is the seam that records it. -
❌ Raise the engine's business exceptions yourself —
ActionFailedException,FlowExpiredException,AwaitSignalTimeoutException,ChildWorkflowFailedException,ChildWorkflowExpiredException,ChildWorkflowCancelledException. A rollback is planned by replayinghandle(), and that replay ends where a seam raises one of them off the run's history. One raised anywhere else is a fault: the plan is abandoned and the throw surfaces to whoever asked for the rollback. Raise an exception of your own instead. -
❌ Catch the engine's control-flow exceptions (
FlowSuspended/InternalFlowControl) as if they were errors. If you use a broadcatch (\Throwable $e), re-throw them:} catch (\Throwable $e) {if ($this->isFlowControl($e)) {throw $e;}// real error handling}
The history contract
When a replay diverges from the recorded history — a different operation appears at a given
sequence than the one recorded — the engine raises HistoryContractMismatchException. Treat it as
a signal that handle() became non-deterministic or that its code changed incompatibly for an
in-flight run.
A signal-gated retry does not disturb this: a retried step keeps its own
ordinal and reuses its action_runs row, and the waiting itself consumes no ordinal — so downstream
steps land in the same place whether the step retried or not.
Example
public function handle(string $orderId): void
{
// ❌ non-deterministic: a new timestamp on every replay
// $ref = 'INV-'.now()->timestamp;
// ✅ recorded once, reused on replay
$ref = $this->sideEffect('invoice_ref', fn () => 'INV-'.now()->timestamp);
$this->action(CreateInvoice::class, $orderId, $ref)->run();
}