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.
Every application I have worked on eventually grows a process. Not a feature, a process. Something that starts, takes a payment, waits on a third party, occasionally needs a human to look at it, and finishes hours or days later. Checkout is the obvious one, but onboarding, refunds, KYC, subscription downgrades and document signing all have the same shape.
The way we usually build these is by accident. A job charges the card. A listener reacts to the payment webhook. A scheduled command scans a table every hour looking for rows in a particular state. A nullable reviewed_at column appears, then a status enum with nine cases, then a comment above the enum explaining which transitions are legal. What you have built is a state machine, but it is spread across five files and a database column, and nobody can tell you where a given order is without reading all of them.
That is the problem a workflow engine solves. So rather than showing you five disconnected snippets of my package and leaving you to work out how they fit together, I want to do the opposite. We are going to build one real thing, an order fulfilment process for a small store, and let each feature turn up at the point the project actually needs it.
By the end you will have touched every part of the package: steps, context, awaiting signals from webhooks, timeouts, retries with backoff, branching, a human approval, a delayed follow up, saga compensation, events, and the console commands you lean on. Not because I went looking for excuses to use them, but because a real order needs all of them.
Here is the shape of what we are building:
Reserve stock -> Charge the customer -> Risk gate | \ (low risk) (high risk) | \ | Manual review -> Decision | / \ | (approved) (rejected -> refund + release) v / Pack the order -> Ship it -> Follow up in 2 daysTwo of those arrows are waiting. The charge waits on a payment webhook. The manual review waits on a human. That waiting is the whole reason a workflow engine earns its keep here, so let us start there in our heads and build toward it.
Getting set up
composer require juststeveking/workflow-enginephp artisan migrateThe service provider is auto discovered and the migrations ship with the package, so that is genuinely all the setup there is. Two tables appear: workflow_instances, one row per order we are fulfilling, and workflow_signals, an append only log of every webhook and decision that came in.
One thing to get out of the way now, because it trips people up later: the engine drives instances forward with queued jobs, so you need a worker running.
php artisan queue:workNo worker, no movement. Keep that terminal open while you follow along.
A workflow is a list of steps
Let me define the shape of the whole thing before we write a single step. A workflow definition is a name and an ordered list of step classes. That is it.
use JustSteveKing\WorkflowEngine\Contracts\WorkflowDefinitionContract;
final class FulfilOrderWorkflow implements WorkflowDefinitionContract{ public static function name(): string { return 'fulfil_order'; }
public function steps(): array { return [ ReserveStockStep::class, ChargeCustomerStep::class, RiskGateStep::class, ManualReviewStep::class, ReviewDecisionStep::class, PackOrderStep::class, ShipOrderStep::class, DeliveryFollowUpStep::class, ]; }}The order of that array matters more than it looks, and I will come back to it when we branch. For now, read it top to bottom as the happy path.
Register the definition once, usually in a service provider’s boot():
use JustSteveKing\WorkflowEngine\Domain\WorkflowRegistry;
public function boot(): void{ $this->app->make(WorkflowRegistry::class)->register(FulfilOrderWorkflow::class);}The first step, and what a step actually is
Every step is a small class that implements WorkflowStepContract. It does one thing in execute() and returns a StepResult telling the engine what happens next. It also declares two things about itself: how long it is willing to wait for a signal via timeoutSeconds(), and how many times it may run before the engine gives up on it via maxAttempts().
Our first step reserves stock. Reserving stock has a side effect we might later need to undo, because if the order falls apart we want that stock back, so this step also implements CompensatingStep. Hold that thought, we will use it near the end.
use JustSteveKing\WorkflowEngine\Contracts\CompensatingStep;use JustSteveKing\WorkflowEngine\Contracts\WorkflowStepContract;use JustSteveKing\WorkflowEngine\Domain\StepResult;use JustSteveKing\WorkflowEngine\Domain\WorkflowContext;
final class ReserveStockStep implements WorkflowStepContract, CompensatingStep{ public function __construct( private readonly Inventory $inventory, ) {}
public function execute(WorkflowContext $context): StepResult { $this->inventory->reserve( orderId: $context->get('order_id'), lines: $context->get('lines'), );
return StepResult::complete(['stock_reserved' => true]); }
public function compensate(WorkflowContext $context): void { $this->inventory->release($context->get('order_id')); }
public function timeoutSeconds(): ?int { return null; // no signal to wait for, so no timeout }
public function maxAttempts(): int { return 1; }}A few things are worth pointing out, because they are the mental model for the entire package.
Steps are resolved from the container. See that Inventory in the constructor? It gets injected. Your steps are normal, testable classes with normal dependencies, which means you can unit test the interesting one in isolation without booting a workflow at all.
The WorkflowContext is an immutable bag of data that rides along with the order from step to step. You read from it with get(), and you never write to it directly. Instead, StepResult::complete(['stock_reserved' => true]) merges that array into the context for every step that comes after. The immutability here is deliberate. If a step could mutate the context in place and then throw, the row in the database and the object in memory would disagree, and you would spend an afternoon working out why.
StepResult::complete() means “this step is done, move on”. It is one of five outcomes, and we will meet the other four as we go.
Starting an order
With a definition registered and one step written, we can already start an instance. This is what you would call from your checkout controller once an order is created:
use JustSteveKing\WorkflowEngine\Domain\WorkflowEngine;
$instance = app(WorkflowEngine::class)->start( workflowName: 'fulfil_order', aggregateId: (string) $order->id, aggregateType: 'order', initialContext: [ 'order_id' => $order->id, 'customer_id' => $order->customer_id, 'lines' => $order->lines->toArray(), 'total_in_cents' => $order->total_in_cents, ],);That aggregateId and aggregateType pair is quietly one of the most useful ideas in the package, and it pays off the moment a webhook arrives. We are tying this workflow instance to order number 42. Later, when Stripe calls us to say a payment succeeded, it will know nothing about our workflow instance ID. It only knows the order. The aggregate is what lets us find our way back. More on that in a minute.
start() persists a pending instance and returns it. Nothing has run yet. In production the engine’s own queued job calls advance() to run the first step, so you rarely call advance() by hand outside of tests. It helps to know that is what is happening under the hood: one step per advance, then a job queued for the next.
Charging the customer, where the interesting bits live
This is the step that shows off most of the engine, so let us take it slowly.
Charging a card is not like reserving stock. The gateway call can fail transiently and be worth retrying. The actual capture confirmation arrives later, over a webhook, so the step has to pause and wait. And if the whole order later collapses, we owe the customer a refund. That is three separate features in one step.
use JustSteveKing\WorkflowEngine\Contracts\CompensatingStep;use JustSteveKing\WorkflowEngine\Contracts\HasRetryBackoff;use JustSteveKing\WorkflowEngine\Contracts\WorkflowStepContract;use JustSteveKing\WorkflowEngine\Domain\StepResult;use JustSteveKing\WorkflowEngine\Domain\WorkflowContext;
final class ChargeCustomerStep implements WorkflowStepContract, CompensatingStep, HasRetryBackoff{ public function __construct( private readonly PaymentGateway $gateway, ) {}
public function execute(WorkflowContext $context): StepResult { $intent = $this->gateway->createPaymentIntent( customer: $context->get('customer_id'), amount: $context->get('total_in_cents'), );
// We kicked off the charge. Now we wait for the provider to confirm it. return StepResult::await('payment_captured', [ 'payment_intent_id' => $intent->id, ]); }
public function compensate(WorkflowContext $context): void { $this->gateway->refund($context->get('payment_intent_id')); }
public function timeoutSeconds(): ?int { return 900; // give the webhook 15 minutes, then fail the order }
public function maxAttempts(): int { return 3; // a gateway blip should not sink the order }
public function retryBackoff(int $attempt): int { return 10 * $attempt; // 10s, then 20s }}Let me unpack the three things happening here.
Awaiting a signal. StepResult::await('payment_captured', [...]) does not mean “block a worker and sit there”. It means the instance parks itself in the database with the status awaiting, remembers it is waiting for a signal called payment_captured, and then nothing happens. No worker held, no memory used. The order could sit there for fifteen minutes and cost you a single database row. When the webhook arrives, we deliver that signal and the instance wakes up.
The timeout. timeoutSeconds() returns 900, so when the step parks, the engine schedules a delayed job. If payment_captured has not arrived within fifteen minutes, that job fires and fails the order. The clever part, which you get for free, is that the timeout job is tagged with the exact step it was guarding. If the webhook arrives at minute two and the order moves on, the stale timeout that eventually fires at minute fifteen is simply ignored. It cannot reach across time and kill a step the order has long since passed.
Retries with backoff. maxAttempts() is 3, so if createPaymentIntent() throws, or the step returns StepResult::fail(), the engine retries the step rather than failing the order. retryBackoff() spaces those retries out, ten seconds then twenty, so you are not hammering a gateway that is already struggling. If you do not implement HasRetryBackoff, the engine uses an exponential backoff from your config instead. Either way the retry counter is per instance and resets when the step advances, so each step gets a fresh budget.
Waking it up from a webhook
Here is where that aggregate pays off. Your Stripe webhook controller receives a payment_intent.succeeded event. It knows the order, because you put the order ID in the payment intent’s metadata when you created it. It does not know your workflow instance. So you ask the repository which instances for this order are waiting on payment_captured, and you signal them:
use JustSteveKing\WorkflowEngine\Contracts\WorkflowRepositoryContract;use JustSteveKing\WorkflowEngine\Domain\WorkflowEngine;
public function handleStripeWebhook( Request $request, WorkflowEngine $engine, WorkflowRepositoryContract $repository,): Response { $event = $this->verifyAndParse($request);
$awaiting = $repository->findAwaitingSignal( signal: 'payment_captured', aggregateId: (string) $event->metadata['order_id'], aggregateType: 'order', );
foreach ($awaiting as $instance) { $engine->signal( instanceId: $instance->id, signal: 'payment_captured', signalData: ['payment_id' => $event->payment_id, 'risk_score' => $event->risk_score], deliveredBy: 'stripe_webhook', ); }
return response()->noContent();}No correlation table, no stashing instance IDs in Stripe’s metadata. You ask the question “which of my workflows for this order is waiting for this thing?” and you answer it.
Notice we passed risk_score in the signal data. Whatever you send with a signal gets merged into the context, exactly like complete() does. So the next step can read the fraud score the payment provider handed us. That is our bridge into branching.
Two more details on signal() that will save you a debugging session. First, if the webhook is faster than your queue and arrives before the step has even parked, the engine buffers it and consumes it the moment the step does park. You do not lose the race. Second, every delivery is written to workflow_signals, matched or not, so that log is the first place I look when an order is stuck.
Branching on risk, and the ordering wrinkle
Most orders are fine and should go straight to packing. A few are risky and deserve a human’s eyes. That is a branch, and branches are what goto is for.
final class RiskGateStep implements WorkflowStepContract{ public function execute(WorkflowContext $context): StepResult { $risky = $context->get('risk_score', 0) >= 75;
return $risky ? StepResult::complete() // fall through to the manual review that follows : StepResult::goto(PackOrderStep::class); // skip the review entirely }
public function timeoutSeconds(): ?int { return null; }
public function maxAttempts(): int { return 1; }}Now look back at the order of steps in the definition:
ReserveStock, ChargeCustomer, RiskGate, ManualReview, ReviewDecision, PackOrder, ShipOrder, DeliveryFollowUpA low risk order calls StepResult::goto(PackOrderStep::class) and jumps straight over ManualReview and ReviewDecision. A risky order calls StepResult::complete() and simply falls through to ManualReview, the very next step in the list.
This is the one wrinkle worth internalising about branching. An awaiting step, when its signal arrives, resumes at the next step in the sequence. So the manual review branch has to physically sit immediately before the point where both branches rejoin, and the fast path uses goto to leap past it. Lay the steps out with that in mind and branching stays simple. Fight the ordering and it gets fiddly. The engine also protects you here: goto a class that is not in the definition’s sequence and it throws, so you cannot quietly route an order into a step that does not exist.
The human in the loop
The manual review step is, mechanically, exactly the same as the charge step waiting on Stripe. It is waiting for a signal. The only difference is that the signal comes from a person clicking a button in your admin panel rather than from a webhook.
final class ManualReviewStep implements WorkflowStepContract{ public function __construct( private readonly ReviewQueue $reviews, ) {}
public function execute(WorkflowContext $context): StepResult { $this->reviews->open($context->get('order_id')); // surface it to a human
return StepResult::await('review_decision'); }
public function timeoutSeconds(): ?int { return 60 * 60 * 24; // if nobody looks in a day, time it out }
public function maxAttempts(): int { return 1; }}Your admin controller delivers the decision the same way the webhook did:
$engine->signal( instanceId: $instance->id, signal: 'review_decision', signalData: ['approved' => $request->boolean('approved')], deliveredBy: "reviewer:{$request->user()->id}",);The signal carries whether the reviewer approved. But an awaiting step cannot itself decide to fail the order after the signal arrives, it can only park and then advance. So we put a tiny gate step right after it to act on the decision:
final class ReviewDecisionStep implements WorkflowStepContract{ public function execute(WorkflowContext $context): StepResult { if ($context->get('approved') === true) { return StepResult::complete(); // carry on to packing }
return StepResult::fail('Rejected during manual review'); }
public function timeoutSeconds(): ?int { return null; }
public function maxAttempts(): int { return 1; }}That StepResult::fail() is the fourth outcome, and it is the one that sets off the most interesting machinery in the package. Before we get there, let us finish the happy path.
Packing, shipping, and a delayed follow up
Packing and shipping are ordinary complete() steps, so I will not labour them.
final class PackOrderStep implements WorkflowStepContract{ public function execute(WorkflowContext $context): StepResult { // ...tell the warehouse to pack it...
return StepResult::complete(['packed_at' => now()->toIso8601String()]); }
public function timeoutSeconds(): ?int { return null; } public function maxAttempts(): int { return 3; }}The follow up is the one I like, because it shows the fifth and last outcome, sleep. We want to email the customer two days after the order ships to ask how it went. Without a workflow engine that is a scheduled command scanning a table every hour for orders that shipped roughly two days ago. With one it is a step that sends nothing now and picks back up later:
final class DeliveryFollowUpStep implements WorkflowStepContract{ public function execute(WorkflowContext $context): StepResult { Mail::to($context->get('customer_id'))->send(new HowWasYourOrder(...));
return StepResult::complete(); }
public function timeoutSeconds(): ?int { return null; } public function maxAttempts(): int { return 3; }}Wait, that sends immediately. To delay it, the step before it sleeps:
final class ShipOrderStep implements WorkflowStepContract{ public function execute(WorkflowContext $context): StepResult { // ...hand the parcel to the courier...
return StepResult::sleep(60 * 60 * 24 * 2, ['shipped_at' => now()->toIso8601String()]); }
public function timeoutSeconds(): ?int { return null; } public function maxAttempts(): int { return 3; }}StepResult::sleep(172800) advances the cursor to the next step but parks the instance until the wake time. Two days from now, the follow up email goes out. In the meantime the order costs you a database row and nothing else. No cron scanning, no “shipped roughly two days ago” fuzziness.
When it all falls apart: compensation
Now, the rejection. When ReviewDecisionStep returns StepResult::fail(), we have a problem that a simple “mark it failed” does not solve. We already reserved stock and charged the card. Those side effects are still out there in the world. You cannot roll back a payment gateway with a database transaction, so instead you run the inverse operations. This is the saga pattern, and it is why ReserveStockStep and ChargeCustomerStep implemented CompensatingStep all the way back at the start.
When a step fails past its retries, the engine looks at the steps that already completed, finds the ones that know how to compensate, and runs their compensate() methods in reverse order. The charge happened after the reserve, so the refund happens before the stock release. The order ends up failed, but with the money returned and the stock back on the shelf.
You do not wire any of this up. You marked the two steps with side effects as compensatable, and the engine does the rest the moment something downstream fails. The one thing to keep in mind is that compensate() runs outside the database lock, because a refund is a slow external call and you do not want it holding a row lock, and it can be retried, so guard it with an idempotency key. Refunding twice is its own kind of bad day.
If a compensate() itself throws, the engine does not silently swallow it. It fires a StepCompensationFailed event and carries on compensating the rest. A refund that bounces becomes something you can alert on rather than a mystery.
Watching it run
Everything the engine does emits a domain event, fired after the database transaction commits. If you want a dashboard, or Slack alerts, or just a paper trail, this is where you get it without touching the engine:
use JustSteveKing\WorkflowEngine\Events\WorkflowFailed;use JustSteveKing\WorkflowEngine\Events\StepCompensationFailed;
Event::listen(WorkflowFailed::class, function (WorkflowFailed $event): void { Log::warning("Order workflow {$event->instanceId} failed: {$event->reason}");});
Event::listen(StepCompensationFailed::class, function (StepCompensationFailed $event): void { // A refund or stock release threw. Page someone.});There is an event for every transition: started, step completed, step failed, awaiting a signal, signal received, timed out, slept, completed, compensating, compensation failed, failed, and retried. Build your reporting off these and you get time to fulfil, stuck order counts by step, and payment latency for nothing.
Operating it from the terminal
Here is the part you will actually be grateful for. An order is stuck and a customer is emailing support. The question is always the same: where is it, and why? The workflow:show command answers exactly that.
php artisan workflow:show 42You get the status, which step the order is parked on, the full context, the timestamps, and the signal log with any buffered but unconsumed signals flagged. That last part has saved me more than once. A signal sitting there unconsumed usually means someone mistyped the signal name on the sending side.
A few more that earn their place:
php artisan workflow:list # every workflow you have registeredphp artisan workflow:instances --status=awaiting # everything currently parked, with a status summaryphp artisan workflow:signal 42 review_decision --data='{"approved":true}' # push a decision by handphp artisan workflow:retry 42 # re-arm a failed order and try again from where it brokeThat workflow:retry is worth dwelling on. A failed order is terminal, but it is not a dead end. Maybe the refund failed because your gateway had an outage, and now it is back. Retry re-arms the instance at the step that broke and lets it run forward again. You can retry one order or a whole batch of them with --workflow and --force.
And one command belongs on your scheduler rather than your keyboard:
use Illuminate\Support\Facades\Schedule;
Schedule::command('workflow:tick')->everyMinute();Sleeping orders, like our two day follow up, resume via a delayed queue job. Delayed jobs can be lost. A queue restart, a flushed Redis, and suddenly a sleeper never wakes. workflow:tick is the safety net: every minute it finds sleepers whose wake time has passed and re-queues them. It is cheap insurance against the one failure mode that would otherwise leave orders quietly stranded.
The deploy you will not think about
One last thing, and it is the reason I trust this in production. What happens when you edit FulfilOrderWorkflow to insert a step, and deploy, while two hundred orders are mid flight parked on webhooks?
Nothing bad. At start(), the engine snapshots the definition’s step list onto the instance. In flight orders keep running against the shape they started with. Your Tuesday deploy does not renumber the steps under orders that are halfway through. New orders get the new shape, old orders finish on the old one. If you want to track which is which, implement VersionedWorkflowDefinition and give each version a number.
Where to go from here
We built one order fulfilment workflow and, without reaching for anything artificial, used every part of the package: steps and context, awaiting signals from a webhook and from a human, timeouts, retries with backoff, branching with goto, a delayed follow up with sleep, saga compensation, events, and the console commands that make it operable.
The thing I would leave you with is the mindset shift. Once you have a workflow engine, you stop asking “what listener or scheduled job do I need for this” and start asking “what are the steps, and where does this wait?” Answer those two questions honestly and the process almost writes itself. The nine case status enum and the hourly scanning command stop being architecture and go back to being what they always were, which is scaffolding you put up because you had nothing better.
Keep Reading
Building 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
LaravelServer-Side Rate Limiting in Laravel with Fingerprint.dev
Learn how to build device-based rate limiting in Laravel with Fingerprint.dev, Redis caching, and custom middleware to avoid the limits of IP-based throttling.
26 May · 10m read