You Don't Always Need a Workflow Engine to Roll Back a Failed Checkout
Here's a sequence that shows up in almost every Laravel app that talks to the outside world: - Charge the customer's card. - Create a policy (or order, or booking) with a partner API. - Generate a PDF confirmation. Step 3 throws. Maybe the PDF library ran out of memory, maybe the storage disk is full. Whatever the reason, you're now sitting on a charged card and a partner-side record that your own database has no idea exist. DB::transaction() can't save you here - it only knows about your own database. The payment gateway and the partner API have already committed their side of the world, and nothing you do to your own tables will undo that. This is the problem the Saga pattern solves: instead of one atomic transaction, you model the operation as a chain of steps, each with an execute() and a compensate() . If step N fails, you call compensate() on every step that already succeeded, in reverse order - refund the payment, cancel the partner policy - and end up back where you started, minus the failed step. Conceptually simple. The interesting part is what it takes to actually implement compensate() correctly, and that's what the rest of this post is about. What's already out there Before writing anything, I looked at what exists for Laravel. There's real prior art here, and it's worth understanding before picking a tool: - Durable Workflow (formerly Laravel Workflow) and Saga Lara Flow are both built on the same underlying idea: a Temporal-style durable execution engine. Every step's state is persisted to your database, the workflow runs through your queue, and if a worker dies mid-execution the workflow resumes from where it left off - including mid-compensation. You get replay, long-running workflows that span days, signals for waiting on external events, and parallel branches. - There's also at least one package that tries to split the difference - in-memory execution by default with opt-in persistence, closer to what I ended up wanting, but with enough surface area (DAG execution, approval gates, webhook outboxes, dashboards) that it stops feeling lightweight in practice. All of that durability is genuinely valuable - if your workflow can legitimately take hours, or has to survive a deploy mid-flight, you want exactly this. But it has a cost: a queue worker running somewhere, migrations for the workflow-state tables, and a chain of steps that no longer executes inline in the request that triggered it. My payment โ policy โ PDF sequence doesn't need any of that. It runs entirely within one HTTP request, start to finish, in a few hundred milliseconds. Pulling in a queue-backed durable execution engine for that is like reaching for a distributed lock to protect a variable that never leaves one thread. What I actually wanted A synchronous orchestrator: no queue, no database, no migrations. Steps run in the same request that triggered them, and if one fails, the completed ones get compensated before the request returns - the caller finds out immediately, in the same try/catch they'd use for anything else. final class ChargePayment implements CompensatorStep { public function execute(CompensatorContext $context): mixed { $payment = PaymentGateway::charge($context->get('amount')); $context->set('payment_id', $payment->id); return $payment; } public function compensate(CompensatorContext $context): void { PaymentGateway::refund($context->get('payment_id')); } } final class CreatePartnerPolicy implements CompensatorStep { public function execute(CompensatorContext $context): mixed { $policy = PartnerApi::createPolicy($context->all()); $context->set('policy_id', $policy->id); return $policy; } public function compensate(CompensatorContext $context): void { PartnerApi::cancelPolicy($context->get('policy_id')); } } $result = (new Compensator()) ->addStep(new ChargePayment()) ->addStep(new CreatePartnerPolicy()) ->step(execute: fn ($ctx) => Pdf::generate($ctx->get('policy_id')), name: 'generate_pdf') ->run(new CompensatorContext(['amount' => 4999])); if ($result->needsManualCleanup()) { // chain failed AND rollback failed - something is genuinely stranded } That's the whole shape of it. composer require , no config file, no vendor:publish , nothing to migrate. I called the package Compensator, after the term for exactly this kind of undo step - "compensating transaction." The API surface being small was the easy part, though. Getting the failure semantics right - everything that happens around a compensate() call - turned out to be where most of the actual design work was. The part that actually matters: what happens when the rollback itself fails The naive version of this pattern assumes compensate() always succeeds. In practice, the same partner API that just accepted a policy creation can be the one that's flaky thirty seconds later when you try to cancel it. If you stop compensating the moment one rollback call throws, you leave everything before that step un-rolled-back too - which is usually worse than reporting one failure and continuing. So the default behavior is to keep compensating the rest of the chain even after one compensate() throws, and report every failure on the result rather than swallowing it: if ($result->needsManualCleanup()) { foreach ($result->compensationFailures as $failure) { logger()->critical('stranded side effect', [ 'step' => $failure->stepName, 'attempts' => $failure->attempts, 'error' => $failure->exception->getMessage(), ]); } } needsManualCleanup() is deliberately the one flag worth alerting on: the chain failed and the rollback failed. That combination means a real side effect - a charge, a partner record - is sitting out there unresolved, and no amount of retrying inside the request is going to fix it. Someone needs to know. Which raises the next question: since a compensation can fail transiently (the refund API blips for a second), should Compensator retry it? It can, opt-in: (new Compensator()) ->retryCompensation(times: 2, sleepMs: 200) This is where a subtlety I initially missed becomes unavoidable: if compensate() can run more than once - because of a retry, or because a later process resumes an interrupted rollback - it has to be idempotent. "Refund this payment" has to check whether the payment is still refundable before acting, not assume it's the first time anyone's called it: public function compensate(CompensatorContext $context): void { $payment = PaymentGateway::find($context->get('payment_id')); if ($payment?->isRefundable()) { $payment->refund(); } } That single requirement - idempotent by design, not by accident - is probably the most important sentence in the whole package's documentation, and it's the kind of thing that's easy to skip when you're sketching the happy path. The honest limitation Here's the trade-off I don't want to bury: giving up the database means giving up durability. If PHP dies between steps - an out-of-memory fatal, a worker killed mid-deploy - nothing catches that, the rollback never runs, and you're left with a charged card and zero record of it anywhere. A queue-backed durable engine survives exactly this scenario; that's the whole point of persisting state. Compensator can soften this, not solve it - a shutdown handler that attempts the rollback even after a fatal error: (new Compensator()) ->protectAgainstFatals() It's best-effort. Nothing survives SIGKILL or the machine losing power. If a stranded side effect is genuinely unacceptable for what you're building - real money, anything legally binding - that's your signal to reach for a durable engine instead, not to lean harder on a shutdown handler. Pretending otherwise would just be shipping a worse version of the thing I was trying to avoid. Where the line actually is After going through this, the decision isn't "Compensator vs. the durable engines" so much as a question about your workflow's shape: - Runs inside one request, finishes in milliseconds-to-seconds, no need to survive a crash mid-flight โ a synchronous orchestrator is enough, and a queue is pure overhead. - Can legitimately run for minutes, hours, or days; needs to survive worker restarts and deploys; needs to wait on external signals or human approval โ you want the durability a queue + persisted state actually buys you. Don't try to route around it with retries and shutdown handlers. I ended up publishing what I built as sients/compensator - MIT-licensed, on Packagist, PHP 8.2+ / Laravel 12+. If you've hit the same shape of problem - a handful of external calls in one request that need a clean, ordered rollback - it might save you writing the samearray_reverse loop I would have otherwise written by hand. Top comments (0)
Comments
No comments yet. Start the discussion.