- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open the controller in your PHP project that has the most blame churn. The one where every other PR touches it. Count the lines inside the action method, ignoring the docblock and the return at the bottom. If that number is north of 15, the controller is lying about what it is. It says "HTTP entry point" in the docblock and "business logic owner" in its body.
That's the hot take. A controller has three jobs: parse the request into something the application understands, dispatch to whatever does the work, render the result back into HTTP. Parse, dispatch, render. Everything past that is a use case wearing an HTTP costume. Use cases belong in their own class, away from Request and Response.
The 15-line number is not arithmetic. It's a smell threshold. Reviewers stop reading and start scrolling once a controller crosses it. You've already lost the property you were paying the framework for: a thin layer between protocol and application.
The Controller That Grew Up
You've seen this controller. 84 lines of action method, plus 110 lines of private helpers inside the same class. Place an order. Find the customer, validate the cart, compute totals, apply a coupon, call Stripe, write the order, dispatch three events, log the audit trail, then return JSON. One method.
The defense is reasonable: every line is related to placing an order. That part is not wrong. The wrong part is where the lines live.
The controller had quietly absorbed the use case. The reasons are familiar:
- The first version of
placewas 8 lines. Nobody flinched. - Stripe webhook handling got bolted on. Now 24 lines.
- A "premium customer" branch landed. 38 lines.
- Coupon validation moved in from a service "for clarity." 52.
- The audit log was added during a SOC 2 push. 67.
- A
try/catchswallowed three exception types from the gateway. 84.
Each PR was small. Each diff looked sane. The cumulative effect was that the use case had no home, and the controller was the bag where everything fit.
Here is what that file looked like, trimmed:
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\Customer;
use App\Models\Coupon;
use App\Services\StripeClient;
use App\Events\OrderPlaced;
use App\Events\AuditLogged;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CheckoutController extends Controller
{
public function __construct(private StripeClient $stripe) {}
public function place(Request $request): JsonResponse
{
$validated = $request->validate([
'customer_id' => 'required|uuid',
'items' => 'required|array|min:1',
'items.*.sku' => 'required|string',
'items.*.qty' => 'required|integer|min:1',
'items.*.price' => 'required|integer|min:0',
'currency' => 'required|string|size:3',
'coupon_code' => 'nullable|string',
'idempotency_key' => 'required|string',
]);
$customer = Customer::findOrFail($validated['customer_id']);
if ($customer->is_banned) {
return response()->json(['error' => 'banned'], 403);
}
$subtotal = 0;
foreach ($validated['items'] as $item) {
$subtotal += $item['qty'] * $item['price'];
}
$discount = 0;
if (!empty($validated['coupon_code'])) {
$coupon = Coupon::where('code', $validated['coupon_code'])->first();
if ($coupon && $coupon->is_active && $coupon->expires_at > now()) {
$discount = $coupon->percent_off > 0
? intdiv($subtotal * $coupon->percent_off, 100)
: $coupon->amount_off_cents;
}
}
$total = max(0, $subtotal - $discount);
try {
$charge = $this->stripe->charge(
$customer->stripe_id,
$total,
$validated['currency'],
$validated['idempotency_key'],
);
} catch (\Throwable $e) {
Log::error('charge failed', ['err' => $e->getMessage()]);
return response()->json(['error' => 'payment'], 402);
}
$order = DB::transaction(function () use ($validated, $customer, $total, $charge) {
$order = Order::create([
'customer_id' => $customer->id,
'total_cents' => $total,
'currency' => $validated['currency'],
'stripe_id' => $charge['id'],
'status' => 'paid',
]);
foreach ($validated['items'] as $item) {
$order->items()->create($item);
}
return $order;
});
event(new OrderPlaced($order));
event(new AuditLogged('order.placed', $order->id, $customer->id));
return response()->json([
'order_id' => $order->id,
'total' => $order->total_cents,
'currency' => $order->currency,
'status' => $order->status,
], 201);
}
}
Where is the part an engineer could test without booting Laravel? It isn't there. The business rule (subtotal, coupon, total, charge, persist, emit) is hand-cuffed to Request, JsonResponse, Eloquent, and the global event() helper. Swap Stripe for Adyen: you edit a controller. Want a CLI backfill for the same job? Copy-paste the body into a console command. Queue worker for partner imports? Copy-paste again.
The 15-Line Controller
The same endpoint, rewritten so the controller stops doing the work and starts doing only the translation:
<?php
namespace App\Http\Controllers;
use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Application\Order\Errors\CustomerBanned;
use App\Application\Order\Errors\PaymentDeclined;
use App\Http\Requests\PlaceOrderRequest;
use Illuminate\Http\JsonResponse;
final class CheckoutController extends Controller
{
public function __construct(private PlaceOrder $placeOrder) {}
public function place(PlaceOrderRequest $request): JsonResponse
{
try {
$output = $this->placeOrder->execute(
new PlaceOrderInput(
customerId: $request->validated('customer_id'),
items: $request->validated('items'),
currency: $request->validated('currency'),
couponCode: $request->validated('coupon_code'),
idempotencyKey: $request->validated('idempotency_key'),
),
);
} catch (CustomerBanned) {
return response()->json(['error' => 'banned'], 403);
} catch (PaymentDeclined) {
return response()->json(['error' => 'payment'], 402);
}
return response()->json([
'order_id' => $output->orderId,
'total' => $output->totalCents,
'currency' => $output->currency,
'status' => $output->status,
], 201);
}
}
The action method is 15 lines of code if you count generously, fewer if you don't. Three things happen, in this order:
-
Parse. The
PlaceOrderRequestform-request runs the validation rules and hands back a clean array. The controller's job is to copy fields into aPlaceOrderInputDTO. -
Dispatch. One call:
$this->placeOrder->execute($input). The use case lives inApp\Application\Orderand does not import a single Laravel class. It returns a plainPlaceOrderOutputobject. -
Render. Two
catcharms turn application errors into HTTP status codes. The happy path serializes the output DTO into JSON and picks a 201.
Where did the business logic go? Into PlaceOrder, a plain final class that takes its dependencies through the constructor: an OrderRepository, a CustomerRepository, a PaymentGateway, an EventBus, a Clock. None of those are framework types. Each has a fake implementation you can pass in a unit test that runs in milliseconds, not seconds.
The PlaceOrderRequest is a Laravel-specific helper, and that's fine. Form-requests are parsing. They belong in the controller layer.
What "Parse, Dispatch, Render" Earns You
Three concrete payoffs land the moment you finish this refactor.
The CLI version writes itself. A backfill command for orders missed by a webhook outage is a 12-line Artisan command that constructs a PlaceOrderInput from CSV rows and calls $this->placeOrder->execute($input). Zero copy-paste. Same exception arms, different output channel (write to stdout, exit 1 on a PaymentDeclined).
The queue worker writes itself. A Laravel job that handles partner imports does the same thing. Decode the message body, build the input, call the use case, ack or nack based on whether PaymentDeclined was thrown. The business rule never knew it was being driven by RabbitMQ.
Tests get fast and many. With PlaceOrder decoupled from Request, you can spin up the use case with five fake collaborators and write dozens of tests in an afternoon: coupon expired, coupon stacked, banned customer, gateway timeout, idempotency key replay, currency mismatch, partial inventory. None of those tests boot Laravel. They run on phpunit --no-coverage in under a second on a laptop. That's the point of hexagonal. Test speed is feedback-loop speed. Feedback-loop speed decides whether you ship five features a week or one.
The Pushback, Answered
Three objections come up every time someone reads this hot take.
"This is over-engineering for CRUD." True for pure CRUD. A read endpoint that does Model::findOrFail($id) and returns a resource is fine at 4 lines. The 15-line rule is a smell, not a build rule. The threshold matters when the controller has behavior, not when it has a query.
"My controller is 30 lines but they're all validation." Move them. Laravel form-requests, Symfony #[MapRequestPayload], Slim middleware — every framework has a place for parsing rules that is not the controller body. If your validation is 30 lines, that's 30 lines of PlaceOrderRequest, not 30 lines of the action method.
"What about thin controllers calling fat services?" A Service class with one public method that takes a Request and returns a Response is a controller with a different filename. The point is not to add an indirection; the point is to give the use case input and output DTOs it owns. If your "service" imports Illuminate\Http\Request, it's a controller, regardless of where it sits in app/.
A Heuristic That Travels
The number is the headline, but the real heuristic is the three-verb test. Open any controller action and read it line by line:
- Is this line parsing a protocol-shaped input into something the application understands?
- Is this line dispatching to a class that doesn't know what protocol it is?
- Is this line rendering an output DTO into a protocol-shaped response?
If the answer is "no" on all three, move that line into the use case. The controller shrinks; the use case grows; the framework upgrade you've been dreading stops touching business logic.
Fifteen lines is the smoke alarm. Get out before the framework owns your domain.
If this was useful
This is one chapter's worth of argument from Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework. The book walks the full layout: parsing in the controller, use case in the application layer, ports for repositories and gateways, adapters per protocol, and the tests that fall out of doing it that way. Same shape as the Laravel example above, scaled up to a real service.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)