Action Pattern in Laravel: Why I Stopped Using Fat Controllers
The biggest Laravel codebases don’t become hard to maintain because of syntax or framework choices — they become hard because nobody knows where the business logic lives.
Early in my Laravel career, my controllers did everything. Validation, queries, side effects, jobs, notifications — sometimes all inside a single method.
It worked. Until it didn’t.
I once spent nearly an hour tracing a bug through a controller that validated input, created multiple models, dispatched two jobs, and logged activity. Fixing the bug took five minutes. Finding it was the real problem.
After refactoring my third 400-line controller, I realized the framework wasn’t the issue — my structure was.
The fix was surprisingly simple:
Move business logic into dedicated Action classes.
Not for architectural purity. For sanity.
What’s an Action?
An Action is a class with a single public method — handle — that performs one operation.
Not a vague “service” with ten unrelated methods.
Not a dumping ground like UserService.
One class. One responsibility.
final class CreateTeamInvitation
{
public function handle(array $data, User $inviter): Invitation
{
$invitation = Invitation::query()->create([
'team_id' => $data['team_id'],
'email' => $data['email'],
'role' => $data['role'],
'invited_by' => $inviter->getKey(),
'token' => Str::uuid()->toString(),
]);
dispatch(new SendInvitationEmail($invitation));
return $invitation;
}
}
That’s the entire class. Create the invitation, dispatch an email job. The controller calls it and returns the response. The Action doesn’t know about HTTP, requests, or responses.
Before and after
Here’s what a typical controller method looked like before:
// Before: fat controller
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'email'],
'role' => ['required', 'in:admin,member'],
]);
$invitation = Invitation::create([
'team_id' => $request->user()->current_team_id,
'email' => $validated['email'],
'role' => $validated['role'],
'invited_by' => $request->user()->id,
'token' => Str::uuid()->toString(),
]);
dispatch(new SendInvitationEmail($invitation));
activity()->log('Team invitation sent');
return redirect()->route('team.members');
}
And after extracting the Action:
// After: thin controller
public function store(StoreInvitationRequest $request, CreateTeamInvitation $action): RedirectResponse
{
$action->handle($request->validated(), $request->user());
return redirect()->route('team.members');
}
The controller handles HTTP concerns (validation via Form Request, redirects). The Action handles business logic. Each has one job.
Why this works
Testing becomes trivial. You can test the Action directly without touching HTTP:
it('creates an invitation and dispatches email', function () {
Queue::fake();
$user = User::factory()->create();
$team = Team::factory()->create();
$invitation = (new CreateTeamInvitation)->handle([
'team_id' => $team->id,
'email' => '[email protected]',
'role' => 'member',
], $user);
expect($invitation)->toBeInstanceOf(Invitation::class);
expect($invitation->email)->toBe('[email protected]');
Queue::assertPushed(SendInvitationEmail::class);
});
No request mocking. No response assertions. Just call the method and check the result.
Finding code is instant. Need to know how team invitations work? Open Actions/Team/CreateTeamInvitation.php. Need to understand how billing works? It’s in Actions/Billing/ProcessSubscription.php. Every operation maps to a file. You never have to search through a 500-line controller to find the logic you need.
Composition is natural. Actions can inject dependencies and coordinate complex operations:
final readonly class ProcessSubscription
{
public function __construct(
private LimitEnforcement $limits,
private BillingGateway $billing,
) {}
/**
* @param array{plan: string, payment_method: string} $data
*/
public function handle(array $data, Organisation $organisation): Subscription
{
$plan = Plan::where('slug', $data['plan'])->firstOrFail();
$this->limits->validateUpgrade($organisation, $plan);
$externalSubscription = $this->billing->createSubscription(
$organisation,
$plan,
$data['payment_method'],
);
return Subscription::query()->create([
'organisation_id' => $organisation->getKey(),
'plan_id' => $plan->getKey(),
'external_id' => $externalSubscription->id,
'status' => SubscriptionStatus::Active,
]);
}
}
This Action validates subscription limits, creates the external subscription, and persists the record. It injects dependencies via the constructor — Laravel’s container resolves them automatically.
How I organize them
app/Actions/
├── Auth/
│ ├── RegisterUser.php
│ └── VerifyEmail.php
├── Billing/
│ ├── ProcessSubscription.php
│ ├── CancelSubscription.php
│ └── HandleWebhook.php
├── Team/
│ ├── CreateTeamInvitation.php
│ ├── AcceptInvitation.php
│ ├── RemoveTeamMember.php
│ └── UpdateMemberRole.php
└── Notification/
├── CreateChannel.php
├── TestChannel.php
└── SendAlert.php
Subdirectories for domain-specific groups. No “Action” suffix in class names — the namespace makes it clear.
My rules for Actions
- One public method:
handle. If you need a second public method, you need a second Action. - No HTTP awareness. Actions don’t touch
Request,Response, or session. They receive plain data and return results. - Constructor injection for dependencies. Laravel’s container resolves them automatically.
finalandreadonlywhere possible. Actions don’t need to be extended or mutated.- Return the result. Let the caller (controller, job, another action) decide what to do with it.
When to not use Actions
Not every controller method needs an Action. If the operation is trivial — a simple redirect, a basic CRUD store with no side effects — putting it in the controller is fine. I extract to an Action when:
- The logic involves more than just saving data (dispatching jobs, checking limits, coordinating multiple models)
- I need to call the same logic from multiple places (controller, job, command)
- The operation is complex enough that it deserves its own test
The real benefit
After adopting this pattern across my projects, I noticed something: I stopped scrolling through controllers to understand what the app does. The Actions directory is the feature list. Every file name describes a capability. Every class is small enough to read in 30 seconds.
That’s the real value — not architectural purity, but being able to find and understand your own code six months later.