Skip to main content

Command Palette

Search for a command to run...

Laravel Pipeline Rollback: Undo What Already Happened When a Pipe Fails

Coffee Chat - Episode 10

Published
โ€ข12 min read
Laravel Pipeline Rollback: Undo What Already Happened When a Pipe Fails
D

Software Engineer, passionate coder, PHP, gamer, geek and a person who has a curiosity ๐ŸŽ“๐ŸŽฎ๐ŸŽง๐Ÿ’ป

In my last article, I walked you through Laravel's Pipeline pattern: how it works, why it is already everywhere in the framework, and how to build your own multi-step processing flows with it.

If you missed it, go read Laravel's Pipeline Pattern: The Hidden Gem You're Already Using first. This article picks up right where that one ends.

Here is the problem nobody talks about in pipeline tutorials.

You build a six-step pipeline. Validate the order, reserve inventory, charge the card, create the order record, notify the warehouse, send the confirmation email. Everything runs beautifully in sequence. Then, at step four, something breaks. A database deadlock. A constraint violation. An unexpected null.

Steps one through three already ran. Inventory is reserved. The card is charged. And now you have a customer who got charged but has no order in the system.

That is a nightmare scenario. The basic Pipeline facade gives you zero help dealing with it.

This is fine, everything is fine, sitting in fire

This article covers building a pipeline that knows how to undo itself.

The Problem with Naive Pipelines

The failure scenario, spelled out concretely.

You have an order checkout flow:

ValidateCart โ†’ ReserveInventory โ†’ ChargePayment โ†’ CreateOrderRecord โ†’ SendConfirmationEmail

Each step depends on the previous one completing successfully. Fine โ€” that is the whole point of a pipeline.

What happens when CreateOrderRecord throws an exception:

  1. ValidateCart ran. No side effects.

  2. ReserveInventory ran. Stock was decremented in the database.

  3. ChargePayment ran. The customer's card was charged. Real money moved.

  4. CreateOrderRecord throws. Pipeline stops.

  5. SendConfirmationEmail never runs.

The customer gets charged. The stock is reserved. But there is no order record anywhere in your system. No order ID. No way to fulfil it. No confirmation email.

You took money for something that, from your application's point of view, does not exist.

How do you fix it? You could wrap everything in a database transaction and call DB::rollBack(). That handles step four. But it does not undo step three. The payment gateway call is not inside your database transaction. It is an external API call to Stripe or PayPal. You cannot rollBack() an HTTP request.

So you need something smarter. Each pipe needs to know how to undo itself.

The Rollback Contract

First, an interface. Every pipe that can roll back must implement it.

namespace App\Contracts;

use Closure;

interface RollbackablePipe
{
    public function handle(mixed \(payload, Closure \)next): mixed;

    public function rollback(mixed $payload): void;
}

Two methods. handle() is the same signature you already know from the standard Pipeline pattern. rollback() receives the same payload and undoes whatever handle() did.

The pairing is intentional. If handle() charged a card, rollback() refunds it. If handle() reserved stock, rollback() releases it. If handle() wrote a database row, rollback() deletes it.

Building the RollbackPipeline

We are not using the Laravel Pipeline facade directly here. We build our own RollbackPipeline class that adds the rollback stack on top.

namespace App\Pipelines;

use App\Contracts\RollbackablePipe;
use Closure;
use Throwable;

class RollbackPipeline
{
    private array $pipes = [];

    /** @var RollbackablePipe[] */
    private array $completedPipes = [];

    public function send(mixed $payload): static
    {
        \(this->payload = \)payload;

        return $this;
    }

    public function through(array $pipes): static
    {
        \(this->pipes = \)pipes;

        return $this;
    }

    public function thenReturn(): mixed
    {
        return $this->run();
    }

    private function run(): mixed
    {
        \(payload = \)this->payload;

        $pipeline = array_reduce(
            array_reverse($this->pipes),
            function (Closure \(carry, string \)pipeClass) {
                return function (mixed \(payload) use (\)carry, $pipeClass): mixed {
                    /** @var RollbackablePipe $pipe */
                    \(pipe = app(\)pipeClass);

                    try {
                        \(result = \)pipe->handle(\(payload, \)carry);
                        // Only push to the completed stack AFTER handle() succeeds
                        \(this->completedPipes[] = \)pipe;
                        return $result;
                    } catch (Throwable $e) {
                        // This pipe failed. Trigger rollback on all previously completed pipes.
                        \(this->rollbackCompleted(\)payload);
                        throw $e;
                    }
                };
            },
            fn (mixed \(payload) => \)payload
        );

        return \(pipeline(\)payload);
    }

    private function rollbackCompleted(mixed $payload): void
    {
        // Roll back in reverse order: last completed pipe first
        foreach (array_reverse(\(this->completedPipes) as \)pipe) {
            try {
                \(pipe->rollback(\)payload);
            } catch (Throwable $e) {
                // Log the rollback failure but do not halt the rollback chain.
                // A failed rollback is bad, but stopping all remaining rollbacks is worse.
                logger()->error('Rollback failed for pipe ' . get_class($pipe), [
                    'exception' => $e->getMessage(),
                ]);
            }
        }
    }
}

A few key decisions in this class.

$completedPipes grows as pipes succeed. Crucially, a pipe only gets added AFTER its handle() call returns without throwing. If handle() throws, the pipe never makes it onto the stack โ€” which means we will not try to roll it back either (there is nothing to undo).

For execution order: we use array_reduce on a reversed pipes array to build a nested closure chain. This is the same trick Laravel's own Pipeline class uses internally.

For rollback order: in rollbackCompleted(), we reverse the completed stack before iterating. If steps one, two, and three completed, we roll back three first, then two, then one. Last in, first out. The ordering matters for correctness.

For rollback exceptions: if a rollback itself fails, we log it and keep going. Stopping the rollback chain because one rollback threw means every pipe after the failed one also misses its rollback call โ€” almost always worse. Log it, alert your team, continue.

Implementing Rollbackable Pipes

Three pipe classes for the order scenario.

ReserveInventoryPipe

namespace App\Pipes;

use App\Contracts\RollbackablePipe;
use App\Models\Order;
use App\Services\InventoryService;
use Closure;

class ReserveInventoryPipe implements RollbackablePipe
{
    public function __construct(
        private readonly InventoryService $inventory
    ) {}

    public function handle(mixed \(payload, Closure \)next): mixed
    {
        /** @var Order $payload */
        foreach (\(payload->items as \)item) {
            \(this->inventory->reserve(\)item->product_id, $item->quantity);
        }

        return \(next(\)payload);
    }

    public function rollback(mixed $payload): void
    {
        /** @var Order $payload */
        foreach (\(payload->items as \)item) {
            \(this->inventory->release(\)item->product_id, $item->quantity);
        }
    }
}

handle() reserves stock for each item. rollback() releases it. Symmetric and clean.

ChargePaymentPipe

namespace App\Pipes;

use App\Contracts\RollbackablePipe;
use App\Models\Order;
use App\Services\PaymentGateway;
use Closure;

class ChargePaymentPipe implements RollbackablePipe
{
    public function __construct(
        private readonly PaymentGateway $gateway
    ) {}

    public function handle(mixed \(payload, Closure \)next): mixed
    {
        /** @var Order $payload */
        \(chargeId = \)this->gateway->charge(
            customerId: $payload->customer->payment_method_id,
            amount: $payload->total,
            currency: $payload->currency,
        );

        // Store the charge ID on the payload so rollback can reference it
        \(payload->charge_id = \)chargeId;

        return \(next(\)payload);
    }

    public function rollback(mixed $payload): void
    {
        /** @var Order $payload */
        if (! empty($payload->charge_id)) {
            \(this->gateway->refund(\)payload->charge_id);
        }
    }
}

\(payload->charge_id = \)chargeId stores the charge ID on the payload object so rollback() can reference it later. The payload is passed to every rollback() call, so any data attached during handle() is still there when you need to undo it.

CreateOrderRecordPipe

namespace App\Pipes;

use App\Contracts\RollbackablePipe;
use App\Models\Order;
use Closure;

class CreateOrderRecordPipe implements RollbackablePipe
{
    public function handle(mixed \(payload, Closure \)next): mixed
    {
        /** @var Order $payload */
        $payload->save();

        return \(next(\)payload);
    }

    public function rollback(mixed $payload): void
    {
        /** @var Order $payload */
        if ($payload->exists) {
            $payload->forceDelete();
        }
    }
}

handle() persists the order. rollback() hard-deletes it if it was saved. The $payload->exists check matters: if the exception happened during save() itself, the model might not have an ID yet, and the rollback would be a no-op anyway.

Pipes That Cannot Roll Back

Some operations are genuinely irreversible. Sending an SMS. Publishing a webhook event. Writing to an audit log that must never be altered.

Two honest options.

Option 1: No-op rollback with a comment.

namespace App\Pipes;

use App\Contracts\RollbackablePipe;
use App\Models\Order;
use App\Services\SmsService;
use Closure;

class SendOrderSmsNotificationPipe implements RollbackablePipe
{
    public function __construct(
        private readonly SmsService $sms
    ) {}

    public function handle(mixed \(payload, Closure \)next): mixed
    {
        /** @var Order $payload */
        $this->sms->send(
            to: $payload->customer->phone,
            message: "Your order #{$payload->id} is being processed.",
        );

        return \(next(\)payload);
    }

    public function rollback(mixed $payload): void
    {
        // SMS cannot be unsent. No rollback action possible.
        // If the pipeline fails after this point, this is an accepted inconsistency.
        // The customer received an SMS for an order that did not complete.
        // Operations team should be alerted separately.
    }
}

The comment is doing important work here. It documents a deliberate design decision. Future you (or the next developer on this codebase) should know this was a conscious choice, not an oversight.

Option 2: Log for manual intervention.

public function rollback(mixed $payload): void
{
    /** @var Order $payload */
    logger()->warning('Unrecoverable action: SMS was sent but order failed.', [
        'order_id'      => $payload->id ?? 'not yet created',
        'customer_phone' => $payload->customer->phone,
        'action'        => 'Manual review required.',
    ]);
}

This is better when you have an operations team monitoring logs who can take manual action. It turns an invisible problem into a visible one.

What is not acceptable is pretending the rollback happened when it did not.

Wiring It All Together

The call site:

namespace App\Http\Controllers;

use App\Models\Order;
use App\Pipelines\RollbackPipeline;
use App\Pipes\ReserveInventoryPipe;
use App\Pipes\ChargePaymentPipe;
use App\Pipes\CreateOrderRecordPipe;
use App\Pipes\SendConfirmationEmailPipe;
use App\Exceptions\OrderProcessingException;
use Illuminate\Http\JsonResponse;

class CheckoutController extends Controller
{
    public function store(StoreOrderRequest $request): JsonResponse
    {
        \(order = Order::fromRequest(\)request);

        try {
            $order = (new RollbackPipeline())
                ->send($order)
                ->through([
                    ReserveInventoryPipe::class,
                    ChargePaymentPipe::class,
                    CreateOrderRecordPipe::class,
                    SendConfirmationEmailPipe::class,
                ])
                ->thenReturn();

            return response()->json([
                'order_id' => $order->id,
                'status'   => 'confirmed',
            ], 201);
        } catch (OrderProcessingException $e) {
            // Rollback already happened inside the pipeline.
            // Just return a clean error to the client.
            return response()->json([
                'error' => 'Order could not be completed. Please try again.',
            ], 422);
        }
    }
}

On success, you get the processed order back and return 201. On failure, the pipeline has already called rollback() on every completed pipe before re-throwing the exception. By the time your catch block runs, the cleanup is done.

The controller has zero rollback logic. It does not know which pipe failed. It does not know what was rolled back. It just knows the operation succeeded or failed. That is clean separation.

Testing the Rollback Pipeline

Each pipe is its own class, so you can test the rollback behaviour precisely. A test that verifies rollback is triggered in the correct order when CreateOrderRecordPipe fails:

namespace Tests\Unit\Pipelines;

use App\Models\Order;
use App\Pipelines\RollbackPipeline;
use App\Pipes\ReserveInventoryPipe;
use App\Pipes\ChargePaymentPipe;
use App\Pipes\CreateOrderRecordPipe;
use PHPUnit\Framework\TestCase;
use Mockery;

class RollbackPipelineTest extends TestCase
{
    public function test_rollback_is_called_in_reverse_order_when_pipe_fails(): void
    {
        $order = new Order(['total' => 99.99, 'currency' => 'USD']);

        // Track rollback call order
        $rollbackLog = [];

        $inventoryPipe = Mockery::mock(ReserveInventoryPipe::class);
        $inventoryPipe->shouldReceive('handle')->once()->andReturnUsing(
            fn (\(payload, \)next) => \(next(\)payload)
        );
        $inventoryPipe->shouldReceive('rollback')->once()->andReturnUsing(
            function () use (&$rollbackLog) {
                $rollbackLog[] = 'inventory';
            }
        );

        $paymentPipe = Mockery::mock(ChargePaymentPipe::class);
        $paymentPipe->shouldReceive('handle')->once()->andReturnUsing(
            fn (\(payload, \)next) => \(next(\)payload)
        );
        $paymentPipe->shouldReceive('rollback')->once()->andReturnUsing(
            function () use (&$rollbackLog) {
                $rollbackLog[] = 'payment';
            }
        );

        $orderPipe = Mockery::mock(CreateOrderRecordPipe::class);
        $orderPipe->shouldReceive('handle')->once()->andThrow(
            new \RuntimeException('Database deadlock.')
        );
        // rollback should NOT be called on this pipe, it never completed
        $orderPipe->shouldNotReceive('rollback');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Database deadlock.');

        (new RollbackPipeline())
            ->send($order)
            ->through([\(inventoryPipe, \)paymentPipe, $orderPipe])
            ->thenReturn();

        // Rollback should have run: payment first, then inventory (reverse order)
        \(this->assertSame(['payment', 'inventory'], \)rollbackLog);
    }

    public function test_pipeline_returns_payload_when_all_pipes_succeed(): void
    {
        $order = new Order(['total' => 49.99, 'currency' => 'USD']);

        $pipe = Mockery::mock(ReserveInventoryPipe::class);
        $pipe->shouldReceive('handle')->once()->andReturnUsing(
            fn (\(payload, \)next) => \(next(\)payload)
        );
        $pipe->shouldNotReceive('rollback');

        $result = (new RollbackPipeline())
            ->send($order)
            ->through([$pipe])
            ->thenReturn();

        \(this->assertSame(\)order, $result);
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }
}

The test verifies three things:

  • rollback() is called on ReserveInventoryPipe and ChargePaymentPipe when CreateOrderRecordPipe throws.

  • rollback() is NOT called on CreateOrderRecordPipe itself because it never completed.

  • The rollback order is reversed: payment rolled back before inventory.

That 70s Show celebration Yes! it works

That test is your safety net. Any time you change pipe order or add a new pipe, you know exactly whether rollbacks are firing correctly.

When to Use This Pattern

Not every pipeline needs rollback support. Here is when it earns its place.

Financial transactions. Any time real money is involved, you need compensating actions. A charged card with no order record is not an edge case you can ignore.

Multi-service integrations. When your pipeline calls out to external services, each call is a potential failure point. If the shipping API call fails after you have already created the customer in the CRM, you have partial state across two systems.

Distributed operations without transactions. Database transactions give you rollback for free inside a single database. The moment you cross a service boundary, you lose that guarantee.

Anywhere partial completion is worse than no completion. If the customer would rather see "order failed, please try again" than "order confirmed" followed by silence and a missing delivery, you need rollback.

For purely internal pipelines that touch only your database, a DB::transaction() wrapper is often simpler and good enough. Use this pattern when you have side effects that live outside your database.

The Bigger Picture: the Saga Pattern

What you just built is a local implementation of the Saga pattern. In distributed systems, a Saga is a sequence of transactions where each step has a corresponding compensating transaction. If any step fails, the compensating transactions for all completed steps execute in reverse order.

That is exactly what RollbackPipeline does.

The Saga pattern is foundational in distributed system design. Microservices architectures use it constantly because they cannot wrap cross-service operations in a database transaction. Each service manages its own data, so if service A completes but service B fails, service A needs to know how to undo itself.

You just built the single-application version of that. Understanding it at this scale makes the distributed version much easier to reason about when you encounter it.

When your pipeline fails at step four, you can clean up steps one through three automatically instead of manually hunting down inconsistent state at 2 AM.

Soul Train It Works celebration dance