- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: 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
A team I talked to last quarter spent six weeks building an internal admin tool. Six views. One table. List, view, create, edit, delete, plus a filter screen. The codebase had a domain layer, two ports, an in-memory adapter, a Doctrine adapter, and a use case object for every form submission. Eighteen files. The same shape they had used the year before on a real production service.
When the support team asked for a new editable column, it took two days. Domain entity, DTO, repository signature, mapping, controller, form, template. The form had two fields.
The framework version of that admin tool is a single Route::resource, a controller with seven methods, and six Blade templates. A junior engineer ships it in a day. The new column takes twenty minutes.
Hexagonal architecture is a good idea. It earns its keep on services that survive vendor migrations, team rotations, and three rounds of framework upgrades. It does not earn its keep on every PHP file you write. Pretending it does is how you end up with a cathedral around what should have been a Blade template.
This post is the counter-essay to every "go hexagonal from day one" article. There are codebases where ports and adapters are the wrong investment, and you should be able to name them out loud.
The math everyone skips
Every architecture decision has two columns. What you pay, and what you get.
What you pay for the hexagonal version of a PHP service is real. A domain that does not import Laravel. A port for every outbound dependency. A use case for every operation. An in-memory adapter for every test. Six files where Eloquent would have given you one. A new joiner on day three asks why there is an OrderRepository interface, an EloquentOrderRepository, and an InMemoryOrderRepository for what looks like one thing. A senior engineer spends forty minutes explaining the dependency rule before any code gets written.
What you get is also real. A test suite that runs in under a second. A domain that does not change shape when Laravel 13 becomes Laravel 14. A new entry point: a webhook, a CLI command, or a queue worker, added without rewriting the business logic. The ability to swap MySQL for Postgres in an adapter without the domain noticing. A four-year-old service that still feels like a six-month-old one.
The heuristic underneath it is short:
Architecture earns its keep when complexity × lifespan > cost of formality.
Short or simple projects? Formality costs more than the chaos it would prevent. Long or complex ones flip it: the chaos costs more, and the formality wins.
Most PHP code is short, simple, or both. That is not a bug in the language or the community. It is what PHP is good at.
Four contexts where hexagonal is the wrong answer
1. The CRUD admin nobody talks about
A back-office tool. One developer. One model: internal feature flags, a list of partner companies the support team needs to edit, a tax-rate matrix. Six views. Expected lifetime of eighteen months, after which the company will either rip it out or hand it to a vendor.
You do not need a domain layer for this. You do not need ports. You do not need use cases. You do not need an in-memory adapter to test the repository.
You need Laravel.
// routes/web.php
Route::resource('partners', PartnerController::class);
// app/Http/Controllers/PartnerController.php
final class PartnerController extends Controller
{
public function index()
{
return view('partners.index', [
'partners' => Partner::query()
->orderBy('name')
->paginate(50),
]);
}
public function store(StorePartnerRequest $request)
{
Partner::create($request->validated());
return redirect()->route('partners.index');
}
// edit, update, destroy: same shape.
}
Seven methods, six Blade templates, one migration, one happy-path feature test that proves the create form does not 500. That is the entire build.
Try to put it through ports and adapters and you produce eighteen files instead of seven, take a week instead of two days, and force the next person to learn vocabulary the surrounding tooling does not use. The cost is a week of velocity tax plus an ongoing cognitive cost for everyone who reads the directory. The benefit is zero. There is no future where a partners-admin tool gets a CLI entry point, a queue worker, a different database, or a webhook integration. It is a CRUD form on a CRUD table for a small audience, and it will stay that way until someone deletes it.
Ship the Laravel version. Move on.
2. The 200-line operations script
There is a file in your scripts/ directory that runs once a quarter. It opens a connection to the production read replica, runs three queries, writes a CSV, and emails it to finance. It has been there for two years and it works.
You do not need a domain layer. You do not need a port for the database. You do not need a use case. You do not even need a test suite in the meaningful sense; the script is the test. If the CSV is wrong, finance tells you within ten minutes.
#!/usr/bin/env php
<?php
declare(strict_types=1);
$pdo = new PDO(
getenv('READ_REPLICA_DSN'),
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
);
$rows = $pdo->query(
"SELECT country, SUM(amount_cents) AS revenue
FROM orders
WHERE created_at >= date_trunc('quarter', now())
GROUP BY country"
)->fetchAll(PDO::FETCH_ASSOC);
$out = fopen('/tmp/quarterly-revenue.csv', 'w');
fputcsv($out, ['country', 'revenue_cents']);
foreach ($rows as $r) {
fputcsv($out, [$r['country'], $r['revenue']]);
}
fclose($out);
That is the correct artifact. PDO, three queries, fputcsv. Architecture investment: zero. Test investment: a manual run against staging the day before the report goes out.
Wrapping this in a ReportingUseCase with a RevenueRepository interface and a CsvWriterPort does not make it safer. It makes it three files instead of one, and you still have to delete every one of them the day finance changes the columns they want.
3. The prototype the product team will probably throw away
The company is not sure a feature is worth building. Someone asks for a clickable version, end to end, to put in front of five customers next week. If three of them light up, the feature gets a real budget. If they do not, the code is deleted on a Tuesday and nobody remembers it.
This is not a place for architecture investment. It is a place for speed. The hypothesis is the asset. The code is a disposable artifact that exists to find out whether the asset is worth anything.
Use the framework defaults. Use the highest-level abstraction the framework offers. If Laravel has a starter kit that gets you login and a CRUD scaffold in four hours, use it. If Symfony's MakerBundle generates the controller, the entity, the form, and the template in one command, use it. The future you are designing for is the future where the feature is not built, and you are negotiating the cost of that future against the cost of being late to the validation.
The pathology to avoid is the engineer who treats every prototype as a chance to practice good architecture. They build it the right way, take three weeks instead of three days, miss the validation window, and end up with a beautifully decoupled codebase for a feature the business does not want. The prototype was supposed to answer should we build this? They answered can I build this cleanly? Different question, wrong time.
Burn it later. The disposability is the design.
4. The webhook receiver that exists to call one function
A third-party sends you events when a payment settles. Your job is to verify the signature, update one row, and acknowledge. That is the whole job.
final class StripeWebhookController
{
public function __invoke(Request $request): Response
{
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret'),
);
if ($event->type === 'charge.succeeded') {
Payment::where('stripe_id', $event->data->object->id)
->update(['status' => 'settled']);
}
return response('', 200);
}
}
That handler is correct. The Stripe SDK is doing the signature work. Eloquent is doing the persistence. Your code is the glue.
The hexagonal version asks you to introduce a PaymentGatewayEventPort, a StripeWebhookAdapter that translates the SDK type into a domain event, a MarkPaymentSettledUseCase, and a PaymentRepository interface. Five files. Each one has to be tested. The test for the adapter is the test for the SDK. And the use case test? You are testing UPDATE payments SET status='settled' WHERE stripe_id = ?. The domain event has no behavior on it.
The investment is real, and on this handler it pays back zero. One entry point: the webhook. One outbound dependency: the database. One operation: flip a row. Nothing in there will ever swap, branch, or grow a second consumer.
If a year from now the same handler has to also notify the warehouse, fire an email, and write to an event log, then you have a different problem and you refactor toward a use case. Until then, the controller is enough.
The two traps
There are two ways teams get this wrong. They are mirror images.
The first trap is the over-engineered CRUD app. A team reads a book like this, gets excited, and applies the full architecture to the next project, which turns out to be an internal partners admin tool. Three months later, the tool ships. It works. The team is exhausted. The next CRUD form takes a week instead of a day, because every new field has to travel through a domain entity, a use case, a DTO, a repository interface, and a Doctrine mapping before it shows up in the form. The formality tax compounds with no benefit. The application never grows into the shape the architecture was prepared for.
The second trap is the perennial framework-coupled mess. A team that has been burned by trap one swears off architecture entirely. They ship a small Laravel service. It grows. They add features. By year three, the order logic lives in twelve controller methods and seven OrderService classes, all of them calling Order::find($id)->update([...]) and reaching directly into the Eloquent model. The team complains that Laravel is hard to test, hard to upgrade, hard to extend. The team is wrong. Laravel is fine. They built a critical production service the way they built the partners admin tool, and the bill is now due.
The first trap is loud. The team is visibly suffering, the suffering shows up in velocity, leadership notices.
The second trap is quiet. The team is shipping, slowly, every quarter, and nobody can quite point at what is wrong. The bill arrives over years, in the form of every senior engineer rolling their eyes at the legacy service and asking when the rewrite is scheduled.
Both traps come from the same mistake: treating "use architecture" or "do not" as a binary that applies to every codebase the team owns. It is a per-project judgement, made at the start and revised when the project changes shape.
The signal that it is no longer overkill
How do you know a codebase has crossed over from "Laravel is fine" to "this needs ports"? Four markers. Any one of them is a yellow flag. Two at the same time is the moment. Three and you are already late.
More than three developers in the codebase. Two developers can keep a framework-coupled service in their heads. They know which OrderService has the cancellation logic and which has the refund logic. They communicate by walking over to each other. By the time a fourth developer joins, the verbal map breaks down. Either the codebase has structure a new joiner can read alone, or onboarding takes a month and bugs come from people not knowing where the rules live.
Test runs exceed five minutes. A test suite that takes thirty seconds is part of the development loop. A test suite that takes ten minutes is something developers avoid running locally, which means they push without running it, which means CI is the test runner, which means feedback loops measure in hours. The cause is almost always the same: every test boots the framework, hits a real database, and runs through the HTTP layer. The fix follows the same pattern. A domain layer with tests that need none of that, and a smaller pool of integration tests for the adapters.
You cannot add a new entry point without changing five files. The business wants the same operation exposed over a CLI command in addition to the HTTP controller. The controller has the logic. The controller method is 200 lines. You either copy-paste 200 lines into a Symfony console command, or you spend a week extracting the logic into something both can call. The extraction you should have done two years ago is now a week of work and a regression risk. This is the moment hexagonal would have paid for itself.
The framework's upgrades force a refactor every time. Laravel 11 changed the bootstrap layout. Laravel 12 changed something else. Symfony 7 deprecated a recipe. If your codebase tracks every framework opinion this closely, every minor version is a refactor budget, because the framework's shape is your application's shape. A codebase that treats the framework as an adapter does not have this problem; the framework moves and the application does not notice.
When you see two of these in the same codebase, stop adding features for a sprint and start extracting. Pick one operation, the one the business is asking for the new entry point on, and pull the logic into a use case that does not import the framework. The first extraction takes a week. The second takes a day. By the fifth, the team has the vocabulary, and the cost of formality has dropped to where it pays back.
Start at the right altitude
There are three cases when you sit down to start a new PHP service.
The project is short and simple, and you are sure. Admin tool, reporting script, prototype, webhook receiver. Default to framework-native. Ship fast. The end.
The project is long and complex, and you are sure. The order service that will earn the company money for the next eight years. The integration layer that talks to four external systems and has to keep working across vendor migrations. Default to hexagonal. Pay the formality tax up front. Get the payoff over the lifespan.
The project is somewhere in between, and you are not sure. This is most teams most of the time. The honest answer is to default to the lighter side and add architecture as complexity arrives. Start with Eloquent models in app/Models/. Add a use case the first time a controller method needs to coordinate three things instead of one. Add a port the first time you want to test a controller without hitting a real Stripe sandbox. Add a domain entity the first time an invariant gets broken because two code paths set the same field to different values.
This is not lazy. It is honest. Every decision in hexagonal is reversible while the codebase is small. The cost of refactoring a 5,000-line Laravel app to introduce a use case layer is a weekend. The cost of refactoring a 200,000-line Laravel app to do the same thing is a year. The judgement is about when the codebase crosses from the first category to the second. Friction is the marker, not size.
The architecture in the book is a tool. Tools have contexts. The most expensive mistake is using ports too early on code that was never going to need them, and then carrying the tax for the lifetime of a CRUD form. Late adoption costs less.
Pick the right tool. Then earn the right to make it more elaborate.
If this was useful
The book that goes with this post is honest about exactly this. It spends 26 chapters teaching ports, adapters, use cases, and the dependency rule, and then it stops to write a chapter called When This Is Overkill because the architecture is not the goal. The codebase outliving the framework is the goal. Sometimes the best way to get there is a Blade template and a Route::resource. The book tells you which case you are in.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)