Thin Controllers — What They Are and How to Keep Them That Way

Thin Controllers — What They Are and How to Keep Them That Way

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 5 of 35 | Level: Beginner–Intermediate | Prerequisites: Folder Structure


What You’ll Learn

  • The single job of a controller
  • What “thin” looks like in practice with real code
  • The five things controllers must never do
  • How to recognize when a controller is getting fat
  • The delegation pattern — how controllers hand off to services and actions

The Controller’s Only Job

A controller does exactly four things:

  1. Validate the incoming request (via a Form Request or inline)
  2. Authorize the action (via a Policy or $this->authorize())
  3. Delegate the work to a Service or Action
  4. Respond with the appropriate HTTP response

That’s it. No business logic. No database queries. No foreach loops. No third-party API calls.

If you have logic in a controller that would be difficult to unit test without an HTTP request, it’s in the wrong place.


What a Thin Controller Looks Like

Here is a real controller from the huntvedealer project — the entire dashboard:

// app/Http/Controllers/Admin/DashboardController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Enums\CacheKeys;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\User;
use App\Models\WarrantyClaim;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;

class DashboardController extends Controller
{
    public function index(): Response
    {
        $year = (int) date('Y');

        $orderData = Cache::flexible(CacheKeys::DashboardOrders->value, [1800, 3600], static fn (): array => [
            'total'          => Order::count(),
            'last30Days'     => Order::lastDays(30)->count(),
            'previous30Days' => Order::previousDays(30)->count(),
        ]);

        $claimData = Cache::flexible(CacheKeys::DashboardClaims->value, [1800, 3600], fn (): array => [
            'total'          => WarrantyClaim::count(),
            'last30Days'     => WarrantyClaim::lastDays(30)->count(),
            'previous30Days' => WarrantyClaim::previousDays(30)->count(),
            'pending'        => WarrantyClaim::pending()->count(),
        ]);

        return Inertia::render('Admin/Dashboard', [
            'orderData' => $orderData,
            'claimData' => $claimData,
        ]);
    }
}

Notice what this controller does NOT do:

  • No raw SQL
  • No foreach loops
  • No if/else business logic
  • No sending emails
  • No direct model mutations

It reads cached aggregates (which are just counts) and passes them to the view. That’s a thin controller.


A Controller Store Method Done Right

Here’s how a typical store method should look:

// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
    public function __construct(
        private readonly CreateOrderAction $createOrder
    ) {}

    public function store(StoreOrderRequest $request): RedirectResponse
    {
        $this->authorize('create', Order::class);

        $order = $this->createOrder->execute(
            user: $request->user(),
            data: OrderCustomerData::fromArray($request->validated()),
            cartItems: Cart::items(),
        );

        return redirect()
            ->route('orders.show', $order)
            ->with('success', 'Order placed successfully.');
    }
}

The controller:

  1. Uses a Form Request (StoreOrderRequest) for validation — zero validation code in the controller
  2. Checks authorization via $this->authorize()
  3. Delegates to an Action (CreateOrderAction)
  4. Returns a redirect

That’s all. The CreateOrderAction does the real work.


Five Things Controllers Must Never Do

1. Business Logic

// ❌ Business logic in a controller
public function store(Request $request): JsonResponse
{
    $order = Order::create($request->validated());

    // calculating discounts, applying coupons, updating inventory...
    if ($request->has('coupon')) {
        $coupon = Coupon::where('code', $request->coupon)->first();
        if ($coupon && $coupon->isValid()) {
            $discount = $coupon->calculateDiscount($order->total);
            $order->update(['discount' => $discount, 'total' => $order->total - $discount]);
            $coupon->increment('uses');
        }
    }

    return response()->json($order);
}

// ✅ Delegate to an Action
public function store(StoreOrderRequest $request): JsonResponse
{
    $order = $this->createOrder->execute(
        OrderCustomerData::fromArray($request->validated())
    );

    return response()->json(new OrderResource($order));
}

2. Direct Eloquent Queries

// ❌ Query logic in a controller
public function index(): View
{
    $orders = Order::where('user_id', auth()->id())
        ->where('status', 'pending')
        ->with(['items', 'items.product'])
        ->orderBy('created_at', 'desc')
        ->paginate(15);

    return view('orders.index', compact('orders'));
}

// ✅ Use a model scope or service
public function index(): View
{
    $orders = Order::forUser(auth()->user())
        ->pending()
        ->withItems()
        ->latest()
        ->paginate(15);

    return view('orders.index', compact('orders'));
}

3. External API Calls

// ❌ API calls in a controller
public function processPayment(Request $request): JsonResponse
{
    $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
    $intent = $stripe->paymentIntents->create([
        'amount'   => $request->amount,
        'currency' => 'usd',
    ]);
    // ...
}

// ✅ Delegate to a service
public function processPayment(StorePaymentRequest $request): JsonResponse
{
    $result = $this->stripeService->charge(
        $request->validated('amount'),
        $request->validated('payment_method_id')
    );
    // ...
}

4. Sending Emails or Notifications

// ❌ Sending mail from a controller
public function store(StoreOrderRequest $request): RedirectResponse
{
    $order = Order::create($request->validated());
    Mail::to($order->user)->send(new OrderConfirmation($order));
    Notification::send($order->user, new OrderPlaced($order));
    return redirect()->route('orders.show', $order);
}

// ✅ Fire an event — let a listener handle the notification
public function store(StoreOrderRequest $request): RedirectResponse
{
    $order = $this->createOrder->execute($request->validated());
    // OrderObserver fires events; no notification code needed here
    return redirect()->route('orders.show', $order);
}

5. Long if/else chains

If you find yourself writing more than 2–3 lines of conditional logic in a controller, stop. That logic belongs in a service, an action, or a policy.


The 30-Line Rule

A controller method should rarely exceed 30 lines. If it does, ask yourself:

  • Is there business logic that should move to a Service?
  • Are there multiple operations that should move to an Action?
  • Is there validation that should move to a Form Request?
  • Is there authorization that should move to a Policy?

Usually at least one of these answers is yes.


RESTful Controllers

Keep your controllers RESTful. Use only the standard seven methods:

MethodHTTP VerbRoute
index()GET/orders
create()GET/orders/create
store()POST/orders
show()GET/orders/{order}
edit()GET/orders/{order}/edit
update()PUT/PATCH/orders/{order}
destroy()DELETE/orders/{order}

If you need a non-standard action (e.g., approvearchiveresend), create a new controller for it:

// ❌ Non-RESTful method in a resource controller
class OrderController extends Controller
{
    public function approve(Order $order): RedirectResponse { ... }
    public function archive(Order $order): RedirectResponse { ... }
}

// ✅ Dedicated controllers for non-RESTful actions
class ApproveOrderController extends Controller
{
    public function __invoke(Order $order): RedirectResponse { ... }
}

class ArchiveOrderController extends Controller
{
    public function __invoke(Order $order): RedirectResponse { ... }
}

// routes/web.php
Route::post('orders/{order}/approve', ApproveOrderController::class)->name('orders.approve');
Route::post('orders/{order}/archive', ArchiveOrderController::class)->name('orders.archive');

Single-action controllers (with __invoke()) are perfect for this pattern.


Key Takeaways

  • A controller’s job is validate → authorize → delegate → respond. Nothing else.
  • Never put business logic, queries, API calls, or email sending in a controller.
  • The 30-line rule: if your method exceeds 30 lines, something is in the wrong place.
  • Use RESTful methods only. For non-standard actions, create a dedicated single-action controller.
  • Inject dependencies via the constructor — this makes the controller’s dependencies explicit and testable.

Tips and Gotchas

⚠️ Warning: A controller that does validation, queries the database, sends emails, and dispatches jobs is a God Controller. The tell: methods longer than 30 lines, or more than 2-3 class dependencies injected. When you spot this, extract to an Action or Service immediately — it only gets worse.

💡 Tip: The controller’s return type is documentation. OrderResource tells the reader exactly what this endpoint returns. JsonResponse tells them nothing. Use Resource return types everywhere.

🔥 Expert Note: $this->authorize() being the second line of every controller method (after $request parameter) is a discipline that prevents authorization gaps. If you ever skip it, you’ll one day forget it on a sensitive endpoint. Make it a reflex.

Further Reading


← Multi-Guard Auth | Next: Form Requests and Validation →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

3 Comments