Fat Controllers Die Hard
After extracting traits into services, the controllers were thinner, but still fat. A typical store() method in OrdersController did:
- Validate input
- Create the order
- Create related records
- Upload documents
- Send email notifications
- Fire events for audit trails
- Send chat notifications
- Redirect with flash message
That's eight responsibilities in one method. When Claude looked at this controller to understand how orders work, it had to parse all eight concerns interleaved together. When it needed to build an API controller for the same operation, it would copy-paste the web controller and try to adapt it. Badly.
The fix: Actions.
The Action Pattern
An Action is a single-purpose class with one public method: execute().
namespace App\Actions\Orders;
class CreateOrderAction
{
public function __construct(
private NotificationInterface $notifications,
private AnalyticsService $analytics,
) {}
public function execute(CreateOrderRequest $request, User $user): CreateOrderResult
{
// Validate preconditions
abort_unless($user->organization_id, 422, 'User has no organization.');
// Create the order
$order = Order::create([
'user_id' => $user->id,
'organization_id' => $user->organization_id,
'status' => 'pending',
// ...
]);
// Handle documents
$this->uploadDocuments($request, $order);
// Side effects
event(new OrderCreated($order));
$this->notifications->sendOrderNotification($order, 'Created', 'New order filed.');
$this->analytics->logOrderCreated($user, $order->id, 'web');
return CreateOrderResult::succeeded($order);
}
}
The Result DTO:
class CreateOrderResult
{
public function __construct(
public readonly bool $success,
public readonly ?Order $order = null,
public readonly ?string $error = null,
) {}
public static function succeeded(Order $order): self
{
return new self(success: true, order: $order);
}
public static function failed(string $error): self
{
return new self(success: false, error: $error);
}
}
Now the controller is thin:
// Web controller
public function store(CreateOrderRequest $request, CreateOrderAction $action)
{
$result = $action->execute($request, auth()->user());
return redirect()->route('orders.show', $result->order);
}
// API controller — same Action, different response format
public function store(CreateOrderRequest $request, CreateOrderAction $action)
{
$result = $action->execute($request, auth()->user());
return new OrderResource($result->order);
}
The web controller returns a redirect. The API controller returns JSON. The business logic is identical because it lives in the Action, not the controller.
This accidentally created the perfect migration bridge. When I later migrated features from web controllers to API controllers, the Action already existed. I just wired it up to a new controller with a different response format. No duplication. No drift.
The Extraction Sequence
Like the services, I extracted Actions one at a time:
| Action | From |
|---|---|
CreateOrderAction |
OrdersController::store() |
UpdateOrderStatusAction |
OrdersController::approve/deny() |
CreateTicketAction |
TicketsController::store() |
ApproveTicketAction, DenyTicketAction
|
TicketsController |
EntityCalculator |
EntityController |
CreateEntityAction |
EntityController |
CreateOrganizationAction |
OrganizationsController |
CreateCustomOrderAction |
CustomOrdersController |
Each extraction followed the same pattern:
- Write tests for the existing behavior (if not already covered)
- Create the Action class
- Create the Result DTO
- Move logic from the controller to the Action
- Wire the controller to use the Action
- Run the full test suite
- Ship it
TDD drove the whole process. If I was extracting CreateOrderAction, I first wrote tests against the Action's execute() method directly. Those tests defined the contract. Then I moved the code.
Laravel Policies: Authorization Done Right
Before Policies, authorization was scattered across controllers:
// Inline role checks everywhere
if (!$user->isAdmin() && !$user->isOrgAdmin()) {
abort(403);
}
// Or worse, duplicated across methods
if ($user->role->name !== 'Admin') {
abort(403);
}
This is a maintenance nightmare. Every controller does its own authorization. An agent building a new controller has to figure out which role checks to copy. Get it wrong, and you have a security hole.
Laravel Policies centralize authorization into one place per model:
namespace App\Policies;
class OrderPolicy
{
public function view(User $user, Order $order): bool
{
if ($user->isAdmin()) {
return true;
}
if ($user->isOrgAdmin()) {
return $order->organization_id === $user->organization_id;
}
return $order->user_id === $user->id;
}
public function approve(User $user, Order $order): bool
{
return $user->isAdmin() || $user->isReviewer();
}
}
Now the controller just says:
$this->authorize('view', $order);
One line. The Policy handles the role logic. The controller doesn't know or care about roles.
Role-Scoped Query Builders
Related to Policies, I also extracted role-scoped query builders. Instead of every controller having:
if ($user->isAdmin()) {
$orders = Order::all();
} elseif ($user->isOrgAdmin()) {
$orders = Order::where('organization_id', $user->organization_id)->get();
} else {
$orders = Order::where('user_id', $user->id)->get();
}
We now have:
$orders = OrderQueryBuilder::forUser($user)->get();
The query builder encapsulates the scoping logic. The controller doesn't know about roles. The agent doesn't need to figure out scoping. It just calls ::forUser($user).
The Cross-Company Security Fix That Started It All
I should mention what kicked this off. Early in the project, we found cross-company data leakage. While this change did not enter production, it was serious, as users from Company A could see data from Company B because the controllers weren't consistently scoping queries.
A couple of PRs fixed the immediate issues. But the root cause was architectural: authorization logic was scattered and inconsistent. There was no single source of truth for "who can see what."
Policies and role-scoped query builders weren't just a nice refactoring. They were the systemic fix for a class of security bugs. Once every query goes through OrderQueryBuilder::forUser() and every action checks $this->authorize(), cross-organization leakage becomes structurally impossible.
This is what "design for security" looks like in practice. Not penetration testing after the fact, but making the insecure path harder to write than the secure one.
Why Obvious Architecture Beats Documentation
After these refactorings, the codebase has a clear pattern:
Request → Controller → authorize() → Action::execute() → Result DTO → Response
↓
Services (notifications, CRM, etc.)
Events (audit trails)
Query Builders (scoped data access)
When Claude needs to build a new feature, it doesn't need a 50-page architecture document. It can look at any existing Action and follow the same pattern. It can look at any existing Policy and understand the authorization model.
Make the right thing easy and the wrong thing hard. If the Action pattern is established and every existing feature uses it, the agent will use it too. If authorization goes through Policies, the agent will add policy checks. Not because it read documentation, but because that's the pattern it sees everywhere.
This is "convention over configuration" taken to its logical conclusion. The codebase is the documentation.
The Takeaway
- Fat controllers are an agent liability. If your logic lives in controllers, the agent will copy-paste across controllers and create drift.
- Actions create a migration bridge. Same logic, different response format. Web today, API tomorrow.
- Policies centralize authorization. One source of truth beats scattered inline checks.
- Query builders centralize scoping. Role-based data access in one place, not everywhere.
- Architecture is the best documentation. Clear patterns are self-reinforcing — the agent follows what it sees.
At this point in the project, we had: tests, linting, CI, services with contracts, Actions with DTOs, Policies, and query builders. The codebase was getting healthy. But we still had a big problem: two frontends.
Top comments (0)