TECHNICAL NEWS & REPORTS

Intlight Tech Insights & Engineering Reports

HOMENews & Blog

A curated collection of deep-dive technical articles on Nuxt 4 architecture, Multi-Region Kubernetes, Zero-Trust WAF, Microservices, and Autonomous AI Agents.

MicroservicesTECHNICAL NEWS & REPORTS

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..."
S
SientAuthor:
17/8/2026 6 phút
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:

  1. Charge the customer's card.
  2. Create a policy (or order, or booking) with a partner API.
  3. 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 same array_reverse loop I would have otherwise written by hand.

🔗 Nguồn bài viết gốc: Sient

Discussions & Comments13

Leave a Technical Comment

Share your architectural thoughts or ask technical questions...

H
Hannah SchmidtDevOps & CI/CD Lead
1 day ago

Can confirm: automated canary deployments with Argo Rollouts and Prometheus metrics analysis prevented several outages for our payment gateways.

E
Elena RostovaLead SRE & Platform Architect
45 mins ago

The KEDA autoscaling setup with custom Prometheus metrics is production-grade. We observed a 60% compute cost reduction after switching to event-driven pod scaling.

L
Liam O'ConnorFrontend Performance Specialist
3 hours ago

Nuxt 4 with selective hydration and zero-JS interactive islands delivers mind-blowing speed. Sub-100ms INP and 99+ Core Web Vitals out of the box.

L
Lucas MoreauCloud Native Developer
12 hours ago

Kafka event streaming with schema registry ensures backward compatibility even as payload models evolve across microservice boundaries.

A
Alexander WrightPrincipal Systems Architect @ Stripe
20 mins ago

Superb architectural breakdown! The hybrid L1 in-memory + L2 distributed Redis cache pattern is crucial for mitigating high-concurrency thundering herd issues.

D
David ChenStaff Infrastructure Engineer
12 mins ago

Totally agree, Alexander. Pairing that with singleflight request deduplication on the backend virtually eliminates DB spikes.

SPOTLIGHT & LATEST NEWS

Hot Trending Topics

View All →
I Thought I'd Lost the Plot. I Was Writing It. 🔥 HOT SPOTLIGHT
AI Agents6 min read

I Thought I'd Lost the Plot. I Was Writing It.

I Thought I'd Lost the Plot. I Was Writing It. I set out to build autonomous...

Explore
What Is the Circuit Breaker Pattern? A Practical Guide 🔥 HOT SPOTLIGHT
Microservices6 min read

What Is the Circuit Breaker Pattern? A Practical Guide

What Is the Circuit Breaker Pattern? A Practical Guide for Developers Imagine your...

Explore
I attacked my own npm package before launching it. It let the proposer approve their own writes 🔥 HOT SPOTLIGHT
Security6 min read

I attacked my own npm package before launching it. It let the proposer approve their own writes

My library exists so a human approves an LLM's UPDATE before it runs. It never checked that the approver was somebody other than the proposer — and wrote \"approved\" into the audit trail anyway.

Explore
Build an MCP Server in Go (Part 1): Designing a diagnostic-grade Kubernetes client 🔥 HOT SPOTLIGHT
Kubernetes6 min read

Build an MCP Server in Go (Part 1): Designing a diagnostic-grade Kubernetes client

This post designs the Kubernetes client. The next post wraps it as an MCP server and wires it to an...

Explore
The Write Policy Is the Hard Part: Promotion Pipelines for Agent Memory 🔥 HOT SPOTLIGHT
AI Agents6 min read

The Write Policy Is the Hard Part: Promotion Pipelines for Agent Memory

Storing agent memory is easy. Deciding what earns a permanent write, and keeping the write-path alive through RBAC and network policy, is the real work.

Explore
I Changed How I Think About AI Memory 🔥 HOT SPOTLIGHT
AI Agents6 phút

I Changed How I Think About AI Memory

I Changed How I Think About AI Memory When I first built Lean AI Memory, I focused too...

Explore
Real-Life Refactoring Example: ~3x Less Code to Read 🔥 HOT SPOTLIGHT
Microservices6 phút

Real-Life Refactoring Example: ~3x Less Code to Read

There is a popular idea that refactoring is making code shorter. It is not entirely wrong....

Explore
The Tragedy of the Clean-Handed Auditor 🔥 HOT SPOTLIGHT
Security6 phút

The Tragedy of the Clean-Handed Auditor

\"I could save them if they'd only listen...\" Hey, you. Yeah, you: the compliance or governance...

Explore
Related Articles5 articles
View all Microservices →
What Is the Circuit Breaker Pattern? A Practical Guide6 min read
Microservices8/17/2026

What Is the Circuit Breaker Pattern? A Practical Guide

What Is the Circuit Breaker Pattern? A Practical Guide for Developers Imagine your...

Author: Avijit BeraRead Article
Real-Life Refactoring Example: ~3x Less Code to Read6 phút
Microservices8/17/2026

Real-Life Refactoring Example: ~3x Less Code to Read

There is a popular idea that refactoring is making code shorter. It is not entirely wrong....

Author: Valentine ShiRead Article
Protecting Microservices: Implementing End-to-End Encryption Across REST APIs6 phút
Microservices8/17/2026

Protecting Microservices: Implementing End-to-End Encryption Across REST APIs

End-to-end encryption across REST APIs is the difference between a microservices architecture that...

Author: Fu'ad HusnanRead Article
eBPF-Powered Request Tracing in Go Microservices Without Instrumentation Tax6 phút
Microservices17/8/2026

eBPF-Powered Request Tracing in Go Microservices Without Instrumentation Tax

How eBPF uprobes and ring buffers replace manual trace propagation in Go services—mechanics, tradeoffs, and failure modes.

Author: Neeraj SinghiRead Article
Microservices: Building Applications as Independent, Communicating Services6 phút
Microservices17/8/2026

Microservices: Building Applications as Independent, Communicating Services

Microservices: Building Applications as Independent, Communicating Services A practical,...

Author: Rhuturaj TakleRead Article