Why your Laravel controllers should be almost empty
Controllers are a transport layer, not a home for business logic. How I keep Laravel controllers down to a handful of lines using Form Requests, payloads, and Action classes.
Most Laravel codebases I have been asked to look at have the same file in them somewhere. It is usually called something like OrderController, it is somewhere north of four hundred lines, and it has one method that everybody is scared of. Validation at the top, a transaction in the middle, a couple of if statements checking the current status, an email being sent, a webhook being fired, and a response built inline at the bottom.
Nobody set out to write that file. It grew. Each individual change was reasonable, and the sum of them is a method that no one can hold in their head.
I want to walk through how I avoid that, because the fix is not complicated and it is not really about controllers at all. It is about being honest with yourself about what a controller is for.
A controller is a transport layer
Here is the thing that took me longer to internalise than it should have: HTTP is a delivery mechanism, not your application.
Your business has rules. An order can be cancelled while it is pending or cooking, but not once it has been delivered. A lead gets scored when it arrives. A subscription downgrade takes effect at the end of the billing period. None of those rules care whether the request arrived over HTTP, from a queued job, from an Artisan command, or from a test.
So why would you write them inside a class whose entire reason for existing is HTTP?
That is the whole argument. A controller’s job is to take an HTTP request, hand it to something that knows what to do, and turn the result back into an HTTP response. Everything else is somebody else’s job.
What I actually reach for
I use three things, and only three things.
A Form Request validates the incoming data and hands back a typed object. An Action does the work. A response class turns the result into JSON. That is it. There is no service layer and there is no repository, and I will come back to why.
Let’s build the cancellation endpoint I mentioned, properly this time.
The Form Request handles authorisation and validation, and it does one more thing that a lot of people skip - it gives you back a typed payload instead of an array:
<?php
declare(strict_types=1);
namespace App\Http\Requests\Orders\V1;
use App\Http\Payloads\Orders\CancelOrderPayload;use Illuminate\Foundation\Http\FormRequest;
final class CancelOrderRequest extends FormRequest{ public function authorize(): bool { return $this->route('order')->isCancellable(); }
public function rules(): array { return [ 'reason' => ['required', 'string', 'max:500'], ]; }
public function payload(): CancelOrderPayload { return new CancelOrderPayload( reason: $this->string('reason')->toString(), ); }}That payload() method is doing more work than it looks like. Once you have it, nothing downstream of this class ever touches $request->input('reason') or guesses whether a key exists. The action receives an object with typed properties, and your IDE knows about every one of them.
Then the Action, which is where the actual thinking lives:
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\Events\OrderCancelled;use App\Http\Payloads\Orders\CancelOrderPayload;use App\Models\Order;use App\Enums\OrderStatus;
final readonly class CancelOrder{ public function handle(Order $order, CancelOrderPayload $payload): Order { $order->update([ 'status' => OrderStatus::Cancelled, 'cancellation_reason' => $payload->reason, 'cancelled_at' => now(), ]);
event(new OrderCancelled($order));
return $order->fresh(); }}Read that class and you know exactly what cancelling an order means in this system. The status changes, the reason is recorded, the time is stamped, and the rest of the application is told about it. No HTTP anywhere. No $request. Nothing that ties it to the way the instruction arrived.
And now the controller:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Orders\V1;
use App\Actions\Orders\CancelOrder;use App\Http\Requests\Orders\V1\CancelOrderRequest;use App\Http\Responses\JsonDataResponse;use App\Models\Order;use Illuminate\Http\JsonResponse;
final readonly class CancelOrderController{ public function __construct( private CancelOrder $cancelOrder, ) {}
public function __invoke(CancelOrderRequest $request, Order $order): JsonResponse { return new JsonDataResponse( data: $this->cancelOrder->handle( order: $order, payload: $request->payload(), ), ); }}One method. One responsibility. Nothing to test that isn’t already tested somewhere better.
Why not a service class?
This is where I part company with a lot of the advice you will read on this topic, so let me be clear about the reasoning rather than just asserting it.
A service class starts life as OrderService. It has one method. Then it has four. Then somebody adds updateOrderAndNotifyKitchen() because it was easier than making a new file, and eighteen months later you have the fat controller again, just with a different filename. The problem was never the controller. It was putting unrelated behaviour in the same box.
An Action is a class that does one thing, and its name says which thing. CancelOrder cannot quietly grow a refund() method, because at that point you are obviously writing RefundOrder instead. The constraint is the feature.
The repository question is a similar story. I wrote years ago that the Repository Pattern’s benefits largely predate what Eloquent has become, and I still think that. If you are wrapping Eloquent in an interface so that you could theoretically swap the database out, be honest about whether that has ever actually happened on a project you have worked on. You have added a layer of indirection and a mocking target, and got very little back for it.
The part that pays you back
Here is the thing I did not expect when I first started structuring code like this. The benefit is not really readability, although it does read better. It is what it does to your tests.
When your logic lives in a controller, the only way to test it is through the HTTP layer. Every test spins up a request. Every test needs a route, a payload shape, and an authenticated user, even when you are only trying to verify one business rule.
When the logic lives in an Action, you can just call it:
it('records why an order was cancelled', function () { $order = Order::factory()->cooking()->create();
$result = app(CancelOrder::class)->handle( order: $order, payload: new CancelOrderPayload(reason: 'Kitchen closed early'), );
expect($result->status)->toBe(OrderStatus::Cancelled) ->and($result->cancellation_reason)->toBe('Kitchen closed early');});No HTTP. No route. No mocking, either - and if you want the long version of why I feel strongly about that, I wrote about testing Actions rather than mocks separately.
You still write a feature test for the endpoint. You should. But that test is now checking the things a feature test is good at - that the route exists, that authorisation is enforced, that the response shape is right - instead of trying to prove your business rules through a JSON payload.
Where the line actually sits
I said “almost empty” rather than “empty” deliberately, because there is a version of this that goes too far.
If your controller needs to return a different status code for a created resource than an updated one, put that in the controller. If it needs to pick between two response classes based on an Accept header, that is a transport concern and it belongs there. You are not trying to reach zero lines. You are trying to make sure that the lines which are there are all about HTTP.
The test I use is simple, and you can apply it to any controller you have open right now. Read the method and ask whether a single line of it would still need to exist if this feature were triggered by a queued job instead of a request.
If the answer is yes, that line is in the wrong file.
Keep Reading
Controlling Code Quality When an Agent Writes Your Laravel
Specs, ADRs and path-scoped rules. The three layers of context I build around an agent so it writes Laravel the way my codebase does, not the way every tutorial does.
Aug 2026 · 14 min read
LaravelSeven Days in Ten Milliseconds
A workflow that sleeps for three days is not a workflow you can test by waiting. Owning the clock, asserting on absence, and the races you only get one shot at.
Aug 2026 · 10 min read
LaravelBuilding an order fulfilment workflow in Laravel
Build a real order fulfilment workflow in Laravel: signals from webhooks, timeouts, retries, branching, sleep and saga compensation, one step at a time.
Jul 2026 · 19 min read