Workflows::fake() swaps the manager for a Testing\WorkflowsFake behind the facade and in the container, and returns it:
use RoundlyConsulting\Workflows\Facades\Workflows;
$fake = Workflows::fake(); // swaps the facade root AND the container binding, so injected managers are faked
$this->post('/purchase-requests', [...]);
$fake->assertStarted('purchase-approval');
$fake->assertStarted('purchase-approval', fn (StartData $data) => $data->subject?->is($request), times: 1);
$fake->assertEmitted('purchase_request.submitted', fn (EmitData $data) => $data->payload['amount'] === '1200');
$fake->assertEdited('purchase-approval', fn (array $graph) => count($graph['nodes']) === 5);
$fake->assertFlagResolved('purchase-approval', node: 'notify', path: '/recipients');
$fake->assertTouched($request);
$fake->assertWoken(times: 2);
$fake->assertNothingRecorded();
$fake->recorded(WorkflowOperation::Signal); // list<RecordedOperation{operation, context}>- Record-only: every mutating call — start, emit, signal, webhook, create, edit, discard, publish, retire, activate, restore, resolve a flag, rotate a webhook, import, sync, retry, cancel, touch, wake, prune — is recorded and not run. Calls through the HasWorkflows trait (the touch after an update, the cancel after a delete) are recorded too.
- Reads pass through to the real store: definitions, the catalogue, subjects, validation, diffs, overlays, exports, dry runs and graph building (Workflows::graph()).
- An emit’s fan-out is not run: assert the emit, not the starts it would cause. Its event key is still checked first, by the same check the real emit runs: an internal @ key or a malformed one (Order Placed) throws InvalidEventKeyException and records nothing.
- Stand-ins, never null: under the fake a mutating method returns an unsaved stand-in (exists === false) built through the configured model classes — a definition with the key, name and owner scope of the call, a draft version holding the graph, an ImportResult with an unsaved definition and version. retry and cancel hand back the instance they were given. A start returns StartResult{instance: null, created: false, skipped: 'faked'}; emits, wakes, prunes and syncs return empty reports; touch() and signal() return 0. Assert with the fake, not the database.
->passthrough() records and runs:
$fake = Workflows::fake()->passthrough(); // record AND run
$fake->passthrough(false); // back to record-onlyAssertions
Each recorded operation has a positive and a negative assertion (signals and dry runs, which are internal or not recorded, have none). Every assertion is listed on the facade, and failure messages name the key or the instance ULID:
| Operation | Passes when | Negative |
|---|---|---|
| start | assertStarted(string $key, ?Closure $where = null, ?int $times = null) | assertNotStarted($key), assertNothingStarted() |
| emit | assertEmitted(string $event, ?Closure $where = null) | assertNotEmitted($event), assertNothingEmitted() |
| create | assertCreated(string $key) | assertNotCreated($key) |
| edit | assertEdited(string $key, ?Closure $where = null) | assertNotEdited($key) |
| publish | assertPublished(string $key) | assertNotPublished($key) |
| retire | assertRetired(string $key) | assertNotRetired($key) |
| activate | assertActivated(string $key) | assertNotActivated($key) |
| restore | assertRestored(string $key) | assertNotRestored($key) |
| discard draft | assertDraftDiscarded(string $key) | assertDraftNotDiscarded($key) |
| resolve flag | assertFlagResolved(string $key, ?string $node = null, ?string $path = null) | assertNoFlagResolved($key) |
| rotate webhook | assertWebhookRotated(string $key) | assertWebhookNotRotated($key) |
| import | assertImported(?string $key = null) | assertNothingImported() |
| sync | assertSynced(?string $path = null, ?bool $publish = null) | assertNotSynced() |
| inbound webhook | assertWebhookReceived(string $hookId) | assertWebhookNotReceived($hookId) |
| retry | assertRetried(WorkflowInstance $instance) | assertNotRetried($instance) |
| cancel | assertCancelled(WorkflowInstance $instance) | assertNotCancelled($instance) |
| touch | assertTouched(?Model $subject = null) | assertNotTouched(?Model $subject = null) |
| wake | assertWoken(?int $times = null) | assertNotWoken() |
| prune | assertPruned() | assertNotPruned() |
| anything | — | assertNothingRecorded() |
Recorded operations
$fake->recorded(?WorkflowOperation $operation = null) returns list<RecordedOperation> (operation, context), oldest first, for custom assertions:
| WorkflowOperation | Recorded by | context keys |
|---|---|---|
start | start()->dispatch(), startWith(), for()->start(), definition()->start() | data (StartData), key |
emit | emit(), emitWith(), for()->emit() | data (EmitData), event |
signal | bridges (internal) | key, subject |
webhook | triggers()->webhook(), the inbound route | hook, payload |
create_definition | definitions()->create() / createWith() | data, key |
restore_definition | definitions()->restore() | key, owner |
sync | definitions()->sync() | path, owner, publish, dry_run |
edit | definition()->edit() | definition, key, graph |
discard_draft | definition()->discardDraft() | definition, key |
publish | definition()->publish() | definition, key, by |
retire / activate / rotate_webhook | definition()->retire() / activate() / rotateWebhook() | definition, key |
resolve_flag | definition()->resolveFlag() | definition, key, node, path |
import | versions()->import() | owner, key, payload |
retry | instances()->retry() | instance |
cancel | instances()->cancel(), for()->cancelAll(), the HasWorkflows delete hook | instance, reason |
touch | for()->touch(), the HasWorkflows update hook | subject |
wake | wake() | now |
prune | prune() | — |
dry_run | never recorded | — |
Running workflows in a test
use RoundlyConsulting\Workflows\Testing\InteractsWithWorkflows;
uses(InteractsWithWorkflows::class);
it('escalates after two business days', function () {
$this->travelTo(CarbonImmutable::parse('2026-11-06 15:00', 'Europe/Bratislava')); // Friday
$instance = Workflows::start('purchase-approval', $request, $team)->dispatch()->instance;
$this->travelAndWake('P4D'); // move the clock, run workflows:wake, drain the queue
$this->assertInstanceAt($instance, 'approve');
$this->assertInstanceStatus($instance, InstanceStatus::Waiting);
});| Helper | Does |
|---|---|
runWorkflows(?string $connection) | Works the queue until empty (≤ 1000 jobs); no-op on sync. |
travelAndWake($by) | 'P2D', 'PT1H', an interval or an absolute instant: set the test clock, wake, drain. |
fakeStep(string $type, StepResult|string|Closure $result, ?array $outcomes = null) | Script what every node of a step type returns. |
assertStepRan(string $type, ?int $times = null, ?Closure $where = null) | A scripted type ran (that often, matching $where). |
assertStepNotRan(string $type) | A scripted type never ran. |
restoreSteps() | Put the real types back (runs on its own when the test ends). |
assertInstanceAt($instance, $node) | Current node. |
assertInstanceStatus($instance, InstanceStatus) | Status. |
assertPath($instance, list<string>) | The completed node path. |
With workflows.queue.enabled = off everything runs inline and runWorkflows() has nothing to do.
Step fakes
Test a workflow end to end without its real side effects: script a step type’s result, and the real engine still runs — locks, waits, escalations, the step log and the authorization hook (a denial in your policies still fails the step, so a test never bypasses it):
uses(InteractsWithWorkflows::class);
$this->fakeStep('approval', 'rejected', outcomes: ['approved', 'rejected', 'expired']); // approvals not installed: name its outcomes
$this->fakeStep('acme.create_task', fn (StepContext $c) => StepResult::complete(output: ['task_id' => 7]));
Workflows::start('purchase-approval', $request, $team)->dispatch();
$this->runWorkflows();
$this->assertStepRan('acme.create_task', times: 1, where: fn (StepContext $c) => $c->config()['title'] === 'Order');- A string result is an outcome; a StepResult is returned as is; a closure builds one per run.
- A registered type keeps its key, label, config schema, outcomes, side effects, ability and escalation support, so publish validation and authorization behave as in production.
- A type that is not registered (a bridged one whose package is missing) needs $outcomes; it accepts any config (and the reassign escalation, as a no-op) and has no side effects.
- Works with the queue on and off. The real type is restored when the test ends.
Pest expectations
Registered automatically through the package’s Pest plugin; each refreshes the instance first:
expect($instance)->toBeWaitingAt('approve');
expect($instance)->toHaveCompletedVia(['start', 'approve', 'done']);
expect($instance)->toHaveFailedWith('no_approvers');Dry runs in tests
A dry run is a cheap way to assert routing without side effects:
expect(Workflows::definition('purchase-approval', $team)->dryRun()->sample(['amount' => '60000'])->run()->nodes())
->toBe(['start', 'big', 'legal_cfo', 'notify_approved', 'done']);Every model ships a factory (Database\Factories\*Factory) for setting up definitions, versions and instances directly.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.