Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware
#laravel #design-patterns #architecture #php

Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware

3 min read Mohamed Said Mohamed Said

The Pipeline Pattern: Custom Pipelines Beyond Middleware

Most Laravel developers know Pipeline only as the engine behind HTTP middleware. But the same Illuminate\Pipeline\Pipeline class is a first-class citizen you can use anywhere in your domain — order processing, import validation, data transformation, multi-step onboarding flows. If you have a value that must pass through an ordered sequence of discrete, swappable steps, a pipeline is the right tool.

The Core API

The facade is straightforward:

use Illuminate\Support\Facades\Pipeline;

$result = Pipeline::send($payload)
    ->through([
        NormalizeEmail::class,
        CheckDuplicateAccount::class,
        EnrichFromCRM::class,
        PersistRegistration::class,
    ])
    ->thenReturn();

thenReturn() returns the final $payload. Use then(fn ($p) => ...) when you need a custom terminal callback.

Typing Your Pipes

Avoid the stringly-typed $next dance by defining a contract:

namespace App\Pipelines\Registration;

interface RegistrationPipe
{
    public function handle(RegistrationData $data, \Closure $next): RegistrationData;
}

Each pipe implements the interface and returns the (possibly mutated) DTO:

final class NormalizeEmail implements RegistrationPipe
{
    public function handle(RegistrationData $data, \Closure $next): RegistrationData
    {
        $data = $data->withEmail(mb_strtolower(trim($data->email)));

        return $next($data);
    }
}

Because each pipe receives and returns the same typed DTO, your IDE and static analysis tools (PHPStan, Psalm) can verify the entire chain at once.

Injecting Dependencies Into Pipes

The Pipeline class resolves each pipe through the service container, so constructor injection works out of the box:

final class CheckDuplicateAccount implements RegistrationPipe
{
    public function __construct(
        private readonly UserRepository $users,
    ) {}

    public function handle(RegistrationData $data, \Closure $next): RegistrationData
    {
        if ($this->users->existsByEmail($data->email)) {
            throw new DuplicateAccountException($data->email);
        }

        return $next($data);
    }
}

No service locator, no static calls — pure dependency injection.

Conditional and Dynamic Pipe Lists

Build the pipe list at runtime based on context:

$pipes = [
    NormalizeEmail::class,
    CheckDuplicateAccount::class,
];

if ($data->requiresCRMEnrichment()) {
    $pipes[] = EnrichFromCRM::class;
}

$pipes[] = PersistRegistration::class;

$result = Pipeline::send($data)->through($pipes)->thenReturn();

This is far cleaner than a chain of if statements scattered across a service class.

Testing Pipelines With Pest

Because each pipe is a small, focused class, unit testing is trivial:

it('lowercases and trims the email', function () {
    $pipe = new NormalizeEmail();
    $data = RegistrationData::from(email: '  FOO@EXAMPLE.COM  ');

    $result = $pipe->handle($data, fn ($d) => $d);

    expect($result->email)->toBe('foo@example.com');
});

For integration tests, swap a pipe with a fake:

it('skips CRM enrichment in tests', function () {
    $result = Pipeline::send(RegistrationData::from(email: 'a@b.com'))
        ->through([
            NormalizeEmail::class,
            FakeEnrichFromCRM::class, // test double
            PersistRegistration::class,
        ])
        ->thenReturn();

    expect($result->id)->not->toBeNull();
});

When Not to Use a Pipeline

Pipelines shine when steps are ordered, swappable, and share the same payload type. Avoid them for branching workflows (use a state machine), for fire-and-forget side effects (use events), or when steps need to communicate laterally rather than through the shared payload.

Takeaways

  • Pipeline::send()->through()->thenReturn() works anywhere, not just HTTP.
  • Type your pipes with a shared interface and a DTO for IDE and static analysis support.
  • The container resolves each pipe, so constructor injection is free.
  • Build pipe lists dynamically for conditional processing logic.
  • Each pipe is independently unit-testable; swap in test doubles for integration tests.
  • Avoid pipelines for branching or lateral-communication workflows.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I pass object instances instead of class strings to `through()`?
Yes. `through()` accepts an array of class-name strings, already-resolved object instances, or a mix of both. When a string is given, the container resolves it (with DI); when an object is given, it is used directly.
Q02 How do I halt the pipeline early without throwing an exception?
The cleanest approach is to throw a domain exception and catch it at the call site. Alternatively, you can return the payload without calling `$next($data)` inside a pipe, which short-circuits the remaining steps and returns the current payload as the final result.
Q03 Is there a performance cost to using Pipeline over a plain service class?
The overhead is negligible — a small loop and container resolutions. For hot paths (thousands of calls per request), benchmark first, but for typical domain workflows the readability and testability gains far outweigh any micro-cost.

Continue reading

More Articles

View all