Soms werk ik aan projecten waarbij een stuk data door een reeks van bewerkingen heen moet voordat het opgeslagen of teruggestuurd wordt. Denk aan een inkomend formulier dat gefilterd, genormaliseerd, gevalideerd en vervolgens verrijkt moet worden met externe data. De eerste neiging is om dat allemaal in een controller te proppen, of misschien in een service class die na een paar weken uitgroeit tot een monster van driehonderd regels. Laravel heeft een ingebouwde oplossing voor dit soort situaties: de Pipeline-klasse. Die klasse verstopt zich in Illuminate\Pipeline\Pipeline en verdient veel meer aandacht dan hij doorgaans krijgt.
Wat een Pipeline eigenlijk doet
Het idee achter een Pipeline is simpel: je stuurt een object door een reeks van "pipes", en elke pipe kan dat object aanpassen voordat het naar de volgende stap gaat. Iedere pipe ontvangt het object en een $next-closure die de keten voortzet. Roep je $next($payload) aan, dan gaat het object door naar de volgende pipe. Doe je dat niet, dan stopt de keten daar.
Dat patroon kennen de meeste Laravel-developers al, want het is exact hoe middleware werkt. Een HTTP-request gaat door een stapel middleware heen, elke middleware kan de request aanpassen of afbreken, en uiteindelijk belandt het bij de controller. Diezelfde infrastructuur is beschikbaar voor eigen business logic, los van HTTP.
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($payload)
->through([
NormalizeInput::class,
ValidateBusinessRules::class,
EnrichWithExternalData::class,
PersistToDatabase::class,
])
->thenReturn();
De thenReturn()-methode geeft het eindresultaat terug na alle pipes. Wil je iets anders doen met het eindresultaat, dan vervang je thenReturn() door then(fn ($result) => ...).
Pipes schrijven die je kunt testen
Elke pipe is een losse klasse met een handle-methode. Die methode ontvangt het payload-object en de $next-closure. Dat maakt elke stap volledig isoleerbaar: je test een pipe door hem rechtstreeks te instantiëren en aan te roepen, zonder dat je de hele keten hoeft op te zetten.
Stel dat ik een OrderData-object heb dat een bestelling vertegenwoordigt:
class NormalizeOrderData
{
public function handle(OrderData $order, Closure $next): mixed
{
$order->email = strtolower(trim($order->email));
$order->phone = preg_replace('/\D/', '', $order->phone);
return $next($order);
}
}
De klasse doet één ding. Ze weet niets van de andere stappen, kent de controller niet en heeft geen afhankelijkheid van het HTTP-laag. Een unit test voor deze klasse is tien regels:
it('normalizes email and phone', function () {
$order = new OrderData(email: ' [email protected] ', phone: '+31 6 12-34-56-78');
$pipe = new NormalizeOrderData();
$result = $pipe->handle($order, fn ($o) => $o);
expect($result->email)->toBe('[email protected]');
expect($result->phone)->toBe('31612345678');
});
De $next-closure in de test geeft het object gewoon terug zonder verdere verwerking. Zo test je de pipe volledig in isolatie, zonder mocks of service providers.
Foutafhandeling per stap organiseren
Eén van de mooiste dingen aan dit patroon is dat je foutafhandeling per stap kunt opzetten zonder dat fouten door de hele keten bubbelen op een manier die je niet verwacht. Een pipe kan zelf beslissen of hij een exception gooit, het object aanpast of stilletjes stopt.
class ValidateStockAvailability
{
public function __construct(private InventoryService $inventory) {}
public function handle(OrderData $order, Closure $next): mixed
{
foreach ($order->items as $item) {
if (! $this->inventory->isAvailable($item->sku, $item->quantity)) {
throw new InsufficientStockException($item->sku);
}
}
return $next($order);
}
}
Omdat Laravel de Pipeline-klasse gewoon PHP laat uitvoeren, kun je de hele pipeline in een try/catch wikkelen in de service of controller die hem aanroept. Of je vangt specifieke exceptions op per pipe als je daar de voorkeur aan geeft. De pipeline zelf hoeft niks te weten van hoe fouten afgehandeld worden.
Wil je een pipe afhankelijkheden meegeven via de constructor, dan zorgt de service container daarvoor. Laravel lost constructor dependencies automatisch op als je klassen doorgeeft als string, zoals in het eerste voorbeeld. Geef je een instantie door in plaats van een klassenaam, dan beheert de container die niet en is dependency injection jouw verantwoordelijkheid.
Een Pipeline achter een interface brengen
Wanneer een pipeline groeit, loont het om hem achter een eigen klasse te zetten. Dat geeft je een centrale plek voor de configuratie van de stappen, en je kunt de pipeline eenvoudig uitbreiden of de volgorde aanpassen zonder de aanroepende code te wijzigen.
class ProcessOrderPipeline
{
public function __construct(private Pipeline $pipeline) {}
public function execute(OrderData $order): OrderData
{
return $this->pipeline
->send($order)
->through($this->pipes())
->thenReturn();
}
private function pipes(): array
{
return [
NormalizeOrderData::class,
ValidateBusinessRules::class,
ValidateStockAvailability::class,
CalculateShippingCosts::class,
PersistOrder::class,
];
}
}
De controller die deze pipeline aanroept hoeft alleen maar een OrderData-object te maken en het door te sturen. Alles wat er verder mee gebeurt is volledig afgeschermd:
class OrderController extends Controller
{
public function __construct(private ProcessOrderPipeline $pipeline) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = OrderData::fromRequest($request);
$processed = $this->pipeline->execute($order);
return response()->json(['order_id' => $processed->id], 201);
}
}
De controller is zo dun als hij kan zijn. De business logic zit in losse, testbare klassen, en de volgorde van verwerking is op één plek te lezen.
Wat ik in de praktijk ook doe, is conditionele pipes toevoegen op basis van de staat van het payload-object of omgevingsvariabelen. Dat kan door de pipes()-methode dynamisch te maken:
private function pipes(): array
{
$pipes = [
NormalizeOrderData::class,
ValidateBusinessRules::class,
ValidateStockAvailability::class,
];
if (config('features.fraud_detection')) {
$pipes[] = RunFraudDetectionCheck::class;
}
$pipes[] = PersistOrder::class;
return $pipes;
}
Zo blijft de keten flexibel zonder dat je de individuele pipes hoeft aan te passen.
Het Pipeline-patroon lost iets op dat ik in veel Laravel-codebases tegenkom: logica die verspreid is over controllers, service classes en model events, waardoor het moeilijk is om te zien in welke volgorde dingen gebeuren. Met een pipeline is die volgorde expliciet en zichtbaar. Iedere stap heeft een naam, een verantwoordelijkheid en een plek in de rij. Als ik een nieuwe collega het codebase laat zien en ze zien de ProcessOrderPipeline, weten ze binnen dertig seconden wat er met een bestelling gebeurt van aankomst tot opslag. Dat is de waarde van dit patroon, los van alle technische voordelen.