(Advanced — Phase 1)
Hook
Your controller started with three lines handling a form submission. Six months later it's 200 lines deep, doing validation, three different API calls, a PDF generation step, and sending two emails — and nobody on the team wants to touch it. This is the "fat controller," and it's one of the most common ways Laravel codebases quietly become unmaintainable.
What you'll learn
- Why controllers tend to grow out of control in the first place
- What a service class actually is (it's simpler than it sounds)
- How to extract logic out of a controller into a service class, with a real before/after
- When not to bother with this pattern
Prerequisites
- Comfortable building basic CRUD features with controllers and Eloquent
- Some experience with a controller that's grown larger than you'd like
Core explanation
A controller's actual job is narrow: receive the HTTP request, hand off the work, and return a response. The problem is that Laravel makes it so easy to just keep writing code in the controller method that "the work" ends up living there too — business rules, third-party API calls, orchestration logic, all of it.
A service class is just a plain PHP class whose only job is to hold that business logic, separate from the HTTP layer. The controller calls it; it doesn't know or care that Laravel/HTTP exists at all. That separation is the whole point — it means the same logic could be called from a controller, a queued job, or an Artisan command without duplicating anything.
Step-by-step walkthrough
Before — logic living in the controller
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('Not enough stock.');
}
$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);
}
}
This works — but stock checking, price calculation, and email sending are business rules, not HTTP concerns. Testing this requires spinning up a full HTTP request.
Step 1 — Create the service class
php artisan make:class Services/OrderService
Step 2 — Move the business logic into it
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;
}
}
Step 3 — Slim the controller down to its actual job
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);
}
}
The controller now only does HTTP-related work: validate input, call the service, respond. OrderService::placeOrder() can now be unit tested directly, reused in a queued job, or called from an Artisan command — with zero duplication.
Common mistakes
- Turning it into a dumping ground: a service class that just becomes "OrderService does literally everything order-related" isn't better than a fat controller — it's the same problem moved one file over. Keep methods focused on one action each.
- Injecting the service everywhere out of habit: not every controller needs one. A simple index/show method with no business logic doesn't need extraction — this pattern earns its place when there's actual logic to isolate.
- Forgetting dependency injection: manually
new OrderService()-ing inside methods works but throws away Laravel's container benefits (easy mocking in tests, swappable implementations). Type-hint it in the constructor instead, as shown above.
Recap
- Fat controllers happen because Laravel makes it easy to keep adding logic in place
- A service class holds business logic independent of the HTTP layer
- Controllers become thin: validate → call service → respond
- This makes the logic testable, reusable, and easier to reason about — but only extract when there's real logic worth isolating
Where to go next
This pairs naturally with Action classes (single-responsibility, one-method versions of this same idea) — worth reading next if you want an even more granular alternative. If you're dealing with a codebase where this kind of untangling needs to happen across dozens of controllers rather than one, that's precisely the kind of work covered in the codebase audit — happy to talk through what that'd look like for your project.