(Gevorderd — Fase 1)
Introductie
Je controller begon met drie regels die een formulier afhandeld. Zes maanden later is hij 200 regels lang, doet hij validatie, drie verschillende API-calls, een PDF-generatiestap, en verstuurt hij twee e-mails — en niemand in het team wil er nog aankomen. Dit is de "vette controller", en het is een van de meest voorkomende manieren waarop Laravel-codebases stilletjes onhoudbaar worden.
Wat je gaat leren
- Waarom controllers de neiging hebben om uit de hand te lopen
- Wat een service class eigenlijk is (het is simpeler dan het klinkt)
- Hoe je logica uit een controller haalt en in een service class plaatst, met een echt voor/na-voorbeeld
- Wanneer je dit patroon juist niet moet gebruiken
Vereisten
- Comfortabel met het bouwen van basis CRUD-functionaliteit met controllers en Eloquent
- Ervaring met een controller die groter is geworden dan je zou willen
Kernuitleg
De eigenlijke taak van een controller is smal: de HTTP-request ontvangen, het werk doorgeven, en een response teruggeven. Het probleem is dat Laravel het zó makkelijk maakt om gewoon door te gaan met code schrijven in de controller-methode, dat "het werk" uiteindelijk daar blijft hangen — bedrijfsregels, aanroepen naar externe API's, logica, het komt er allemaal terecht. Om dit te voorkomen gaan we gebruik maken van een service class.
Een service class is een normale PHP-klasse waarvan de enige taak is om die bedrijfslogica te bevatten, los van de HTTP-laag. De controller roept de service aan; de service weet niet en het maakt niet uit dat Laravel/HTTP bestaat. Die scheiding is precies het punt — het betekent dat dezelfde logica net zo goed aangeroepen kan worden vanuit een controller, een queued job, of een Artisan-commando, zonder iets te dupliceren.
Stap voor stap
Voor — logica die in de controller leeft
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:1',
]);
$product = Product::findOrFail($validated['product_id']);
if ($product->stock < $validated['quantity']) {
return back()->withErrors('Onvoldoende voorraad.');
}
$order = Order::create([
'user_id' => auth()->id(),
'product_id' => $product->id,
'quantity' => $validated['quantity'],
'total' => $product->price * $validated['quantity'],
]);
$product->decrement('stock', $validated['quantity']);
Mail::to(auth()->user())->send(new OrderConfirmation($order));
return redirect()->route('orders.show', $order);
}
}
Dit werkt — maar voorraadcontrole, prijsberekening en het versturen van e-mail zijn bedrijfsregels, geen HTTP-aangelegenheden. Om dit te testen heb je een volledige HTTP-request nodig.
Stap 1 — Maak de service class aan
php artisan make:class Services/OrderService
Stap 2 — Verplaats de bedrijfslogica ernaartoe
class OrderService
{
public function placeOrder(User $user, Product $product, int $quantity): Order
{
if ($product->stock < $quantity) {
throw new InsufficientStockException($product, $quantity);
}
$order = Order::create([
'user_id' => $user->id,
'product_id' => $product->id,
'quantity' => $quantity,
'total' => $product->price * $quantity,
]);
$product->decrement('stock', $quantity);
Mail::to($user)->send(new OrderConfirmation($order));
return $order;
}
}
Stap 3 — Maak de controller weer slank, alleen zijn eigenlijke taak
class OrderController extends Controller
{
public function __construct(private OrderService $orders) {}
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:1',
]);
$product = Product::findOrFail($validated['product_id']);
try {
$order = $this->orders->placeOrder(auth()->user(), $product, $validated['quantity']);
} catch (InsufficientStockException $e) {
return back()->withErrors($e->getMessage());
}
return redirect()->route('orders.show', $order);
}
}
De controller doet nu alleen nog HTTP-gerelateerd werk: input valideren, de service aanroepen, antwoorden. OrderService::placeOrder() kan nu direct unit getest worden, hergebruikt worden in een queued job, of aangeroepen worden vanuit een Artisan-commando — zonder enige duplicatie.
Veelgemaakte fouten
- Er een verzamelbak van maken: een service class die gewoon uitgroeit tot "OrderService doet letterlijk alles wat met bestellingen te maken heeft" is niet beter dan een vette controller — het is hetzelfde probleem, één bestand verderop. Houd methodes gericht op één actie per stuk.
- De service overal uit gewoonte injecteren: niet elke controller heeft er een nodig. Een simpele index/show-methode zonder bedrijfslogica hoeft niet uitgepakt te worden — dit patroon verdient zijn plek pas als er daadwerkelijk logica is om te isoleren.
- Dependency injection vergeten: handmatig
new OrderService()aanroepen binnen methodes werkt, maar gooit de voordelen van Laravel's container weg (makkelijk mocken in tests, verwisselbare implementaties). Type-hint de service in plaats daarvan in de constructor, zoals hierboven getoond.
Samenvatting
- Vette controllers ontstaan omdat Laravel het makkelijk maakt om logica op zijn plek te blijven toevoegen
- Een service class bevat bedrijfslogica, onafhankelijk van de HTTP-laag
- Controllers worden dun: valideren → service aanroepen → antwoorden
- Dit maakt de logica testbaar, herbruikbaar en makkelijker te doorgronden — maar haal alleen logica eruit als er echt iets is om te isoleren
Wat nu?
Dit sluit natuurlijk aan bij Action classes (single-responsibility, eenmethode-varianten van hetzelfde idee) — de moeite waard om hierna te lezen als je een nog fijnmaziger alternatief wilt. Werk je aan een codebase waar dit soort ontwarring bij tientallen controllers moet gebeuren in plaats van bij één? Dat is precies het soort werk dat onder de codebase-audit valt — ik denk graag met je mee over hoe dat er voor jouw project uit zou zien.