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.