Thin Controllers — What They Are and How to Keep Them That Way
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:
- Validate the incoming request (via a Form Request or inline)
- Authorize the action (via a Policy or
$this->authorize()) - Delegate the work to a Service or Action
- 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
foreachloops - 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:
- Uses a Form Request (
StoreOrderRequest) for validation — zero validation code in the controller - Checks authorization via
$this->authorize() - Delegates to an Action (
CreateOrderAction) - 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:
| Method | HTTP Verb | Route |
|---|---|---|
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., approve, archive, resend), 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.
OrderResourcetells the reader exactly what this endpoint returns.JsonResponsetells them nothing. Use Resource return types everywhere.
🔥 Expert Note:
$this->authorize()being the second line of every controller method (after$requestparameter) 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.
3 Comments