Seven Days in Ten Milliseconds
A workflow that sleeps for three days is not a workflow you can test by waiting. Owning the clock, asserting on absence, and the races you only get one shot at.
Last month I walked through building an order fulfilment workflow with the workflow engine: signals arriving from webhooks, timeouts bounding the wait, retries with backoff, branching, and saga compensation when the whole thing falls over. It was a long article and it left one question completely unanswered.
How do you test the bit where it sleeps for three days?
That is not a rhetorical flourish. Take an abandoned cart reminder. Somebody adds a product to a basket and wanders off. You send a nudge, wait three days, send a final one, and give up. Most of that workflow is waiting. If your suite cannot cover the waiting, it is covering the boring quarter of the process and leaving the interesting three quarters to production.
The good news is that you never have to wait. Not once, not for a second. A sleeping instance is a database row with a wake time on it, and rows do not care what the clock says. Getting a seven day process to run in ten milliseconds is entirely a matter of who owns the clock, and it is not the workflow.
The test that lies to you
Here is the workflow. Two steps, one of which sleeps.
final class AbandonedCartReminder implements WorkflowDefinitionContract{ public static function name(): string { return 'abandoned_cart_reminder'; }
public function steps(): array { return [ SendFirstReminder::class, SendFinalReminder::class, ]; }}
final class SendFirstReminder implements WorkflowStepContract{ public function __construct( private readonly CartRepository $carts, ) {}
public function execute(WorkflowContext $context): StepResult { $cart = $this->carts->find($context->get('cart_id'));
Mail::to($cart->email)->queue(new FirstReminder($cart));
return StepResult::sleep(259_200, ['first_reminder_sent_at' => now()]); }
public function timeoutSeconds(): ?int { return null; }
public function maxAttempts(): int { return 3; }}And here is the test almost everybody writes first, because it is the one the tooling nudges you towards.
it('sleeps for three days', function (): void { $result = app(SendFirstReminder::class)->execute( context: WorkflowContext::make( workflowInstanceId: 'wf_1', aggregateId: (string) $this->cart->id, aggregateType: 'cart', initialData: ['cart_id' => $this->cart->id], ), );
expect($result->sleepSeconds)->toBe(259_200);});That test passes. It will always pass. It is also worth nothing, and for exactly the reason I wrote about in Testing Actions, Not Mocks: it asserts on the shape of a return value rather than on what the system does with it.
Ask yourself what it would catch. Not a missing workflow:tick in the scheduler, which means the sleeper never wakes at all. Not a delayed advance job dropped by a queue restart. Not the second step reaching for first_reminder_sent_at and finding nothing because somebody moved the context update onto a different result. Not the customer who checks out on day two and gets the final reminder anyway, which is the bug your support inbox will actually receive.
Every one of those failures lives on the far side of the sleep. The unit test never goes there.
Own the clock, not the code
The shift that makes this tractable is small. Stop thinking of a sleeping workflow as blocked, and start thinking of it as parked. Nothing is held open. There is no worker sitting on it and no connection waiting. There is a row in workflow_instances with a status of sleeping and a wake_at in the future, and a delayed job that will knock on the door when the time comes.
So a test does not wait for anything. It moves the clock and knocks on the door itself.
use JustSteveKing\WorkflowEngine\Contracts\WorkflowRepositoryContract;use JustSteveKing\WorkflowEngine\Domain\WorkflowEngine;use JustSteveKing\WorkflowEngine\Domain\WorkflowStatus;
beforeEach(function (): void { $this->freezeTime();
Queue::fake(); Mail::fake();
$this->engine = app(WorkflowEngine::class); $this->repository = app(WorkflowRepositoryContract::class);
$this->cart = Cart::factory()->abandoned()->create();});
it('parks on a wake time once the first reminder goes out', function (): void { $instance = $this->engine->start( workflowName: 'abandoned_cart_reminder', aggregateId: (string) $this->cart->id, aggregateType: 'cart', initialContext: ['cart_id' => $this->cart->id], );
$this->engine->advance($instance->id);
expect($this->repository->findById($instance->id)) ->status->toBe(WorkflowStatus::Sleeping);
Mail::assertQueued(FirstReminder::class);});Three things are doing the work. freezeTime() pins the clock so every calculation is relative to a known instant rather than to whenever CI happened to run. Queue::fake() stops the engine’s own AdvanceWorkflow job from running the rest of the workflow out from under you, which is what makes the stepping deliberate. And calling advance() directly drives the state machine synchronously, which is exactly what the package’s own feature suite does. In production the queued job calls the same method. In a test you are simply the queue.
One detail to internalise before you write anything else: advance() runs exactly one step. It executes the step at the cursor, persists the result, and queues a job for the next one. It does not loop. A test that drives the engine by hand needs one advance() per step, and one more at the end to move the cursor past the last step and mark the instance complete.
Asserting on absence
Here is where testing a sleeping workflow stops resembling normal testing.
Most of what we write asserts that something happened. A row exists, a mail went out, a response came back with the right status. With an instance parked on a wake time, the assertions that matter most are negative. The next step has not run. No mail is queued. The context has not gained the key that only the final step adds. The instance is still exactly where you left it.
Proving that a thing correctly has not happened yet is half the job, and almost nothing in the usual testing advice prepares you for it.
The sharpest version is the stray advance. Under an at-least-once queue you will get duplicate and early advance jobs, and the engine ignores one that arrives before the wake time. That guard is a real behaviour with a real failure mode, so it deserves a real test.
it('ignores an advance that lands before the wake time', function (): void { $instance = $this->engine->start( workflowName: 'abandoned_cart_reminder', aggregateId: (string) $this->cart->id, aggregateType: 'cart', initialContext: ['cart_id' => $this->cart->id], );
$this->engine->advance($instance->id);
$this->travel(2)->days();
$this->engine->advance($instance->id);
expect($this->repository->findById($instance->id)) ->status->toBe(WorkflowStatus::Sleeping);
Mail::assertNotQueued(FinalReminder::class);});Read that back as a sentence and it says something useful about the system: two days into a three day sleep, an extra advance changes nothing. Swap the repository for a custom one that quietly skips the wake time check and this test goes red. The unit test from earlier stays green.
Then, and only then, do you let time pass.
it('sends the final reminder once the sleep has elapsed', function (): void { $instance = $this->engine->start( workflowName: 'abandoned_cart_reminder', aggregateId: (string) $this->cart->id, aggregateType: 'cart', initialContext: ['cart_id' => $this->cart->id], );
$this->engine->advance($instance->id); // SendFirstReminder, then sleeps
$this->travel(3)->days()->addMinute();
$this->engine->advance($instance->id); // wakes, runs SendFinalReminder $this->engine->advance($instance->id); // cursor past the end, instance completes
expect($this->repository->findById($instance->id)) ->status->toBe(WorkflowStatus::Completed);
Mail::assertQueued(FinalReminder::class);});Three days and a minute, a few lines apart, no delay anywhere in the run. That is the ten milliseconds in the title.
A sleeping instance is not listening
There is a trap in that workflow, and writing the tests is how you find it.
A sleeping instance is in sleeping, not awaiting. It is not parked on a signal, so it is not listening for one. If the customer completes their checkout on day two, sending checkout_completed at the instance does not cancel anything. With early buffering on, which is the default, the signal is recorded and held for a step that never asks for it, and three days later the final reminder goes out to somebody who already bought the thing.
Sleep is a delay, not a subscription. If a drip campaign needs to be cancellable, the step that wakes up has to re-check the world before it acts.
public function execute(WorkflowContext $context): StepResult{ $cart = $this->carts->find($context->get('cart_id'));
if ($cart->isCheckedOut()) { return StepResult::complete(['skipped_reason' => 'checked_out']); }
Mail::to($cart->email)->queue(new FinalReminder($cart));
return StepResult::complete();}Which gives you the absence test that actually protects the customer.
it('does not send the final reminder to a cart that has since checked out', function (): void { $instance = $this->engine->start( workflowName: 'abandoned_cart_reminder', aggregateId: (string) $this->cart->id, aggregateType: 'cart', initialContext: ['cart_id' => $this->cart->id], );
$this->engine->advance($instance->id);
$this->travel(2)->days();
$this->cart->markCheckedOut();
$this->travel(1)->day()->addMinute();
$this->engine->advance($instance->id); $this->engine->advance($instance->id);
expect($this->repository->findById($instance->id)) ->status->toBe(WorkflowStatus::Completed) ->context->get('skipped_reason')->toBe('checked_out');
Mail::assertNotQueued(FinalReminder::class);});The state changed while the workflow was asleep. That is the defining property of a long-running process, and it is invisible to any test that does not move the clock.
The races you only get one shot at
Sleeping is the easy half. The genuinely nasty paths appear when a signal and a timer are in flight at once, and those are the ones you will never reproduce by hand.
Take the member registration workflow from the package README. ChargeMemberStep sits at index 0, awaits payment_succeeded, and bounds its wait with an hour long timeout. The webhook lands at fifty nine minutes. The step advances. Somewhere out there, a delayed timeout job is still scheduled, and when it fires it must do nothing at all.
That is why the timeout carries the step index it was scheduled to guard. The engine ignores a timeout unless the instance is still awaiting at that exact cursor position. A guarantee you have not tested is a comment, so test it.
it('ignores a timeout scheduled for a step that has already advanced', function (): void { $instance = $this->engine->start( workflowName: 'member_registration', aggregateId: (string) $this->member->id, aggregateType: 'member', initialContext: ['member_id' => $this->member->id, 'plan' => 'annual'], );
$this->engine->advance($instance->id); // ChargeMemberStep parks on the signal
$this->engine->signal( instanceId: $instance->id, signal: 'payment_succeeded', signalData: ['payment_id' => 'pay_123'], deliveredBy: 'stripe_webhook', );
$this->travel(1)->hour()->addMinute();
$this->engine->timeout( instanceId: $instance->id, stepIndex: 0, // the step the timeout was guarding, now long gone );
expect($this->repository->findById($instance->id)) ->status->not->toBe(WorkflowStatus::Failed);});The other one worth writing is the early signal. A fast provider delivers payment_succeeded before the step has finished parking. With buffering on, the signal is held and consumed the moment the step asks for it, and the instance never parks at all. Fake the events, deliver the signal before the first advance, then advance, and assert that SignalReceived arrived with buffered set to true and that WorkflowAwaitingSignal was never dispatched.
Event::assertDispatched( SignalReceived::class, fn (SignalReceived $event): bool => $event->buffered,);
Event::assertNotDispatched(WorkflowAwaitingSignal::class);Absence again, and this time absence is the entire point of the feature.
I would write both of those before either of the happy paths. The happy path is what you check by hand in thirty seconds with workflow:advance and workflow:signal. The races are the ones you get exactly one shot at, at three in the morning, with a customer on the phone.
Time is an input, not an ambient fact
One last thing, and it quietly determines whether any of the above stays true.
Under an at-least-once queue a step can run more than once. If a step computes a deadline from whatever now() says at the instant it happens to execute, two runs produce two different deadlines, and the one that sticks is whichever run persisted last. Your test only agreed with production because you froze the clock. Production has no freezeTime().
Compute deadlines once, at start(), and thread them through the context as values.
$instance = $this->engine->start( workflowName: 'abandoned_cart_reminder', aggregateId: (string) $cart->id, aggregateType: 'cart', initialContext: [ 'cart_id' => $cart->id, 'final_reminder_at' => now()->addDays(3)->toIso8601String(), ],);Now the step reads a value rather than asking the world what time it is, a re-run produces the same answer as the first run, and the test asserts on the same input production will see. Same reasoning as keeping models out of the context and storing identifiers instead. Anything a step derives from the outside world at execution time is something your test has to pin, and the pinning is where the lies creep in.
Get the clock under control and a three day drip campaign becomes an ordinary Pest test that runs in the same breath as everything else. Which leaves the harder question of what happens when a step fails halfway through and you have to unwind the ones that already succeeded.
Compensation is a saga running backwards, and testing a rollback is a different problem entirely. That one is next.
Keep Reading
Building an order fulfilment workflow in Laravel
Build a real order fulfilment workflow in Laravel: signals from webhooks, timeouts, retries, branching, sleep and saga compensation, one step at a time.
24 Jul · 19m read
LaravelBuilding Bulletproof Laravel APIs using Schema-First Contract Validation
Stop letting undocumented fields into your Laravel API. Write the JSON Schema first, then enforce it in middleware, DTOs, and your Pest test suite.
20 Jul · 10m read
LaravelStrangling Procedural Legacy PHP into Laravel
Replace a procedural PHP monolith with Laravel one route at a time. Nginx ingress, session bridging that actually works, and Eloquent on a legacy schema.
20 Jul · 13m read