Laravel Pipeline Pattern Beyond Middleware | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://msaied.com/public) - [Home](https://msaied.com/public)
- [Projects](https://msaied.com/public/projects)
- [Articles](https://msaied.com/public/articles)
- [Certificates](https://msaied.com/public/certificates)
- [About](https://msaied.com/public#about)

           [  Contact](https://msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://msaied.com/public)
- [ProjectsCase studies](https://msaied.com/public/projects)
- [ArticlesEngineering notes](https://msaied.com/public/articles)
- [CertificatesCredentials](https://msaied.com/public/certificates)
- [AboutHow I work](https://msaied.com/public#about)
- [ContactGet in touch](https://msaied.com/public#contact)

  [Start a conversation](https://msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://msaied.com/public)
2. /
3. [Articles](https://msaied.com/public/articles)
4. /
5. Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware

 Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware
======================================================================

 The Pipeline pattern in Laravel is far more powerful than middleware alone. Learn how to build typed, composable pipelines for domain workflows, data transformation, and multi-step validation with full testability.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://msaied.com/public#person) Published 2 Aug 2026 · Updated 2 Aug 2026 · 3 min read

ShareCopy linkCopied

 ![Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware](https://cdn.msaied.com/499/6113dcde1518951ad514082a3699232a.png) 

  On this page +1. [The Pipeline Pattern Beyond Middleware](#the-pipeline-pattern-beyond-middleware)
2. [Why Reach for a Pipeline?](#why-reach-for-a-pipeline)
3. [Defining a Typed Payload](#defining-a-typed-payload)
4. [Writing Stages](#writing-stages)
5. [Assembling the Pipeline](#assembling-the-pipeline)
6. [Short-Circuiting a Stage](#short-circuiting-a-stage)
7. [Testing Stages in Isolation with Pest](#testing-stages-in-isolation-with-pest)
8. [Registering Stages via the Container](#registering-stages-via-the-container)
9. [Takeaways](#takeaways)

 The Pipeline Pattern Beyond Middleware
--------------------------------------

Most Laravel developers know `Pipeline` from HTTP middleware, but the same `Illuminate\Pipeline\Pipeline` class is available anywhere in your application. Used deliberately, it replaces tangled service methods with a clean, composable chain of single-responsibility stages.

### Why Reach for a Pipeline?

Consider an order-submission workflow: validate inventory, apply discount rules, charge payment, dispatch fulfilment, send confirmation. Stuffing all of that into an `OrderService::submit()` method creates a god-object. A pipeline turns each concern into its own class, testable in isolation.

### Defining a Typed Payload

Start with a readonly DTO so every stage shares a common contract:

```php
readonly class OrderContext
{
    public function __construct(
        public Order $order,
        public ?Discount $discount = null,
        public bool $paymentCaptured = false,
    ) {}

    public function withDiscount(Discount $discount): self
    {
        return new self($this->order, $discount, $this->paymentCaptured);
    }

    public function markPaymentCaptured(): self
    {
        return new self($this->order, $this->discount, true);
    }
}

```

Immutable DTOs prevent stages from silently mutating shared state — a common bug in mutable pipeline payloads.

### Writing Stages

Each stage receives the payload and a `$next` closure:

```php
final class ApplyDiscountStage
{
    public function __construct(
        private readonly DiscountResolver $resolver,
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $discount = $this->resolver->forOrder($context->order);

        return $next($context->withDiscount($discount));
    }
}

```

```php
final class CapturePaymentStage
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $this->gateway->capture($context->order, $context->discount);

        return $next($context->markPaymentCaptured());
    }
}

```

Note the explicit return type on `handle`. Laravel's pipeline calls `handle` by convention, but you can customise the method name via `->via('process')` if you prefer.

### Assembling the Pipeline

```php
final class SubmitOrderAction
{
    public function __construct(
        private readonly Pipeline $pipeline,
    ) {}

    public function execute(Order $order): OrderContext
    {
        return $this->pipeline
            ->send(new OrderContext($order))
            ->through([
                ValidateInventoryStage::class,
                ApplyDiscountStage::class,
                CapturePaymentStage::class,
                DispatchFulfilmentStage::class,
                SendConfirmationStage::class,
            ])
            ->thenReturn();
    }
}

```

`thenReturn()` returns the final payload. Use `->then(fn ($ctx) => ...)` when you need a different return value at the end.

### Short-Circuiting a Stage

Sometimes a stage should halt the chain — for example, if inventory is unavailable:

```php
final class ValidateInventoryStage
{
    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        if (! $context->order->hasStock()) {
            throw new InsufficientStockException($context->order);
        }

        return $next($context);
    }
}

```

Throw a domain exception rather than returning early without calling `$next`. This keeps the pipeline contract honest and lets callers handle failures uniformly.

### Testing Stages in Isolation with Pest

```php
it('applies a discount when one is available', function () {
    $resolver = Mockery::mock(DiscountResolver::class);
    $resolver->expects('forOrder')->andReturn(new Discount(10));

    $stage = new ApplyDiscountStage($resolver);
    $order = Order::factory()->make();
    $context = new OrderContext($order);

    $result = $stage->handle($context, fn ($ctx) => $ctx);

    expect($result->discount)->toBeInstanceOf(Discount::class)
        ->and($result->discount->percentage)->toBe(10);
});

```

Because each stage is a plain class with constructor injection, you never need to boot the full application to test it.

### Registering Stages via the Container

Laravel resolves stage class names through the service container, so constructor dependencies are injected automatically. If a stage needs contextual binding, register it in a service provider:

```php
$this->app->when(CapturePaymentStage::class)
    ->needs(PaymentGateway::class)
    ->give(StripeGateway::class);

```

### Takeaways

- Use readonly DTOs as pipeline payloads to prevent silent mutation between stages.
- Each stage is a single-responsibility class — test it without booting the framework.
- Throw domain exceptions to short-circuit; never silently skip `$next`.
- Constructor injection works automatically because Laravel resolves stages via the container.
- `->via('process')` lets you rename the handler method for semantic clarity.
- Pipelines compose naturally with actions, making complex workflows readable and auditable.

- [laravel](https://msaied.com/public/articles?search=laravel)
- [design-patterns](https://msaied.com/public/articles?search=design-patterns)
- [architecture](https://msaied.com/public/articles?search=architecture)
- [testing](https://msaied.com/public/articles?search=testing)

 Frequently asked questions 
---------------------------

  Can I reuse the same Pipeline instance across multiple requests in Octane?No. Bind `Pipeline` as a transient (non-singleton) or resolve it fresh each time via `app(Pipeline::class)`. A shared instance retains the previous payload and stages between requests, causing subtle bugs under Octane's persistent worker model.

   What is the difference between `thenReturn()` and `then()`?`thenReturn()` is syntactic sugar for `-&gt;then(fn ($payload) =&gt; $payload)` — it simply returns the final payload unchanged. Use `-&gt;then(Closure $destination)` when you need to transform or persist the result after all stages have run.

   Should pipeline stages be final classes?Marking them `final` is a good default. Stages represent a single, concrete behaviour; allowing inheritance invites accidental overrides that break the pipeline contract. If you need variation, compose a new stage rather than extending an existing one.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://msaied.com/public#person)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

[About](https://msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleRead/Write Splitting, Connection Pooling, and Sticky Reads in Laravel](https://msaied.com/public/articles/readwrite-splitting-connection-pooling-and-sticky-reads-in-laravel-5) [Next articleLaravel Telescope Alternatives: Building a Lightweight Debug Bar with Custom Watchers](https://msaied.com/public/articles/laravel-telescope-alternatives-building-a-lightweight-debug-bar-with-custom-watchers-1)  

   On this page
-------------

1. [The Pipeline Pattern Beyond Middleware](#the-pipeline-pattern-beyond-middleware)
2. [Why Reach for a Pipeline?](#why-reach-for-a-pipeline)
3. [Defining a Typed Payload](#defining-a-typed-payload)
4. [Writing Stages](#writing-stages)
5. [Assembling the Pipeline](#assembling-the-pipeline)
6. [Short-Circuiting a Stage](#short-circuiting-a-stage)
7. [Testing Stages in Isolation with Pest](#testing-stages-in-isolation-with-pest)
8. [Registering Stages via the Container](#registering-stages-via-the-container)
9. [Takeaways](#takeaways)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/745/8744e1be5136b430da52e9fca3ed3964.png)  · 3 min read### Service Container Deep Dive: Contextual Binding, Tagging, and Method Injection

6 Oct 2026 ](https://msaied.com/public/articles/service-container-deep-dive-contextual-binding-tagging-and-method-injection-1) [ ![](https://cdn.msaied.com/743/8998fac3a41451ab3fe1588194e17a43.png) Filament · 3 min read### Securing Filament Plugins with Plumb: Automated Security Scoring for PHP Packages

5 Oct 2026 ](https://msaied.com/public/articles/securing-filament-plugins-with-plumb-automated-security-scoring-for-php-packages) [ ![](https://cdn.msaied.com/742/2d02018669cdeedccb5de2efb898f0ee.png) Filament · 3 min read### Filament v3.3.56 Released: File Hash Names and Livewire Upload Fix

5 Oct 2026 ](https://msaied.com/public/articles/filament-v3356-released-file-hash-names-and-livewire-upload-fix) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://msaied.com/public)
- [Articles](https://msaied.com/public/articles)
- [Certificates](https://msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://msaied.com/public/sitemap.xml)
