Laravel Academy
Blog / Tutorial

Tutorial

Service Classes vs. Fat Controllers

Controller getting out of hand? Learn how to extract business logic from a fat Laravel controller using a service class, with a real before/after example.

J

Joram

Laravel trainer & developer

20 sep 2026 · 5 min read

(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.

#Tutorial #Advanced
20 sep 2026 Tutorial