NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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-only

Assertions

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:

OperationPasses whenNegative
startassertStarted(string $key, ?Closure $where = null, ?int $times = null)assertNotStarted($key), assertNothingStarted()
emitassertEmitted(string $event, ?Closure $where = null)assertNotEmitted($event), assertNothingEmitted()
createassertCreated(string $key)assertNotCreated($key)
editassertEdited(string $key, ?Closure $where = null)assertNotEdited($key)
publishassertPublished(string $key)assertNotPublished($key)
retireassertRetired(string $key)assertNotRetired($key)
activateassertActivated(string $key)assertNotActivated($key)
restoreassertRestored(string $key)assertNotRestored($key)
discard draftassertDraftDiscarded(string $key)assertDraftNotDiscarded($key)
resolve flagassertFlagResolved(string $key, ?string $node = null, ?string $path = null)assertNoFlagResolved($key)
rotate webhookassertWebhookRotated(string $key)assertWebhookNotRotated($key)
importassertImported(?string $key = null)assertNothingImported()
syncassertSynced(?string $path = null, ?bool $publish = null)assertNotSynced()
inbound webhookassertWebhookReceived(string $hookId)assertWebhookNotReceived($hookId)
retryassertRetried(WorkflowInstance $instance)assertNotRetried($instance)
cancelassertCancelled(WorkflowInstance $instance)assertNotCancelled($instance)
touchassertTouched(?Model $subject = null)assertNotTouched(?Model $subject = null)
wakeassertWoken(?int $times = null)assertNotWoken()
pruneassertPruned()assertNotPruned()
anything—assertNothingRecorded()

Recorded operations

$fake->recorded(?WorkflowOperation $operation = null) returns list<RecordedOperation> (operation, context), oldest first, for custom assertions:

WorkflowOperationRecorded bycontext keys
startstart()->dispatch(), startWith(), for()->start(), definition()->start()data (StartData), key
emitemit(), emitWith(), for()->emit()data (EmitData), event
signalbridges (internal)key, subject
webhooktriggers()->webhook(), the inbound routehook, payload
create_definitiondefinitions()->create() / createWith()data, key
restore_definitiondefinitions()->restore()key, owner
syncdefinitions()->sync()path, owner, publish, dry_run
editdefinition()->edit()definition, key, graph
discard_draftdefinition()->discardDraft()definition, key
publishdefinition()->publish()definition, key, by
retire / activate / rotate_webhookdefinition()->retire() / activate() / rotateWebhook()definition, key
resolve_flagdefinition()->resolveFlag()definition, key, node, path
importversions()->import()owner, key, payload
retryinstances()->retry()instance
cancelinstances()->cancel(), for()->cancelAll(), the HasWorkflows delete hookinstance, reason
touchfor()->touch(), the HasWorkflows update hooksubject
wakewake()now
pruneprune()—
dry_runnever 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);
});
HelperDoes
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 crypto

By 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.