The problem with fat controllers
It starts innocently enough. A store method that validates a request, creates a record, sends an email, and fires an event. Twenty lines. Readable enough.
Six months later that same method is 120 lines. It handles three different code paths depending on the authenticated user’s role. The email is conditional on a feature flag. There is a try-catch that swallows exceptions in one branch but re-throws in another.
This is not a failure of discipline. It is a failure of structure. The controller has no natural boundary to push back against accumulation.
Actions as the answer
An Action is a single-purpose PHP class with one public method — execute or handle — that does exactly one thing. It receives the data it needs as arguments. It performs the work. It returns a result or throws an exception.
class SubscribeAction
{
public function execute(string $email, string $locale = 'en'): void
{
// create subscriber, send email — nothing else
}
}
The controller becomes a thin orchestrator:
public function store(SubscribeRequest $request, SubscribeAction $action)
{
$action->execute($request->validated('email'));
return response()->json(['message' => 'Check your inbox.'], 202);
}
What you gain
Testability. You can unit-test the Action without booting the HTTP layer. Pass in the arguments, assert the side effects.
Reusability. The same Action can be called from a controller, a console command, a job, or a Livewire component.
Readability. The controller tells you what happens at this endpoint. The Action tells you how. The separation of concerns is explicit in the file structure.
Change safety. When the subscribe flow changes — say, you add a source tracking field — you change the Action. The controller and its tests are untouched.
Think of Actions as the verbs of your domain. Name them accordingly: SubscribeAction, PublishPostAction, ConfirmSubscriptionAction. Your codebase becomes a vocabulary for the business problem it solves.