- 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 any senior-PHP Slack channel on a slow Friday afternoon. Someone is insisting Hexagonal is the One True Way. Someone else is insisting Clean is more rigorous. A third person is quoting Cockburn at the first two and a fourth is linking Uncle Bob's onion. By Monday the codebase looks the same as it did on Thursday and the only thing that changed is the temperature of the channel.
You have seen that thread. You may have written some of it. The reason it never produces a different codebase is that Hexagonal vs Clean is mostly an argument about labels, and labels do not make App\Domain\Order\Order import Doctrine or not import Doctrine. Cuts do.
This post stops pretending the two architectures are rival religions. They overlap by design, they disagree on a handful of real things, and the question that matters for your repository on Monday is which cuts to make, not which book to put on the shelf.
What they actually agree on
Cockburn published Ports and Adapters in 2005. The paper is shorter than this blog post. The argument is small: the application should be testable in isolation from its drivers and driven adapters, and you draw it as a hexagon because the shape reminds you it has more than two sides.
Martin published Clean Architecture in 2017. The diagram is the concentric-rings picture: Entities at the core, Use Cases around them, Interface Adapters around those, Frameworks and Drivers at the rim. One rule binds the whole thing: source-code dependencies point inward.
Read them side by side and the structural claim is identical:
- The application has an inside.
- The inside owns the business.
- The outside is plumbing.
- The arrows between them go one way.
In PHP, both architectures produce the same file. An OrderRepository interface in App\Domain\Order\ is a port to a Hexagonal reader and an Interface Adapter contract to a Clean reader. The class is the same. The directory is the same. The constructor injection is the same. Only the noun on the whiteboard changed.
That convergence is telling you something. When two architectures developed independently land on the same code, the disagreements that remain are smaller than they look.
Where they actually disagree
There are four disagreements worth naming, and they are the only ones that change a file on disk.
Depth of cut. Hexagonal makes one cut: application versus adapters. Everything is inside the hexagon or outside it. Clean makes two cuts inside what Hexagonal calls "the application": Entities and Use Cases. The line between them is real in Clean and not drawn in Hexagonal.
Descriptive vs prescriptive. Hexagonal is descriptive. It shows a shape and trusts you. Clean is prescriptive. It names four rings, gives the dependency rule explicitly, and hands you a checklist. Prescription is easier to enforce. Description is easier to adapt.
What crosses the boundary. Hexagonal does not make noise about DTOs. Most Hexagonal PHP code passes domain types directly across the port. Clean is explicit: use cases take an Input DTO and return an Output DTO, deliberately flat, so the boundary is crossable from any direction.
Where the port lives. Hexagonal-leaning code puts OrderRepository next to the entity it serves. Clean-leaning code puts it in the use case's namespace, because the use case is the consumer.
That is the entire material disagreement. The rest is vocabulary noise.
A side-by-side, in code
Take the same operation, "place an order", written under each leaning.
The Hexagonal-flavored use case takes domain types straight across the port:
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use App\Domain\Shared\Money;
final class PlaceOrder
{
public function __construct(
private readonly OrderRepository $orders,
) {}
public function execute(
CustomerId $customerId,
Money $total,
): Order {
$order = Order::place($customerId, $total);
$this->orders->save($order);
return $order;
}
}
The port lives next to the entity:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface OrderRepository
{
public function find(OrderId $id): ?Order;
public function save(Order $order): void;
}
The Clean-flavored version of the same use case puts an explicit DTO at the boundary and parks the port in the application namespace:
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Application\Order\Port\OrderRepository;
use App\Domain\Order\Order;
final class PlaceOrder
{
public function __construct(
private readonly OrderRepository $orders,
) {}
public function execute(PlaceOrderInput $in): PlaceOrderOutput
{
$order = Order::place(
$in->customerId(),
$in->money(),
);
$this->orders->save($order);
return new PlaceOrderOutput(
orderId: $order->id()->toString(),
status: $order->status()->value,
);
}
}
final readonly class PlaceOrderInput
{
public function __construct(
public string $customerId,
public int $totalAmountCents,
public string $totalCurrency,
) {}
// factory helpers omitted; map to domain types.
}
final readonly class PlaceOrderOutput
{
public function __construct(
public string $orderId,
public string $status,
) {}
}
Both run. Both ship. The Clean version is three files instead of one. It pays for itself the first time the HTTP shape changes and the use case does not move.
The decision table
Architecture has a price. You pay it in indirection, in file count, in the time it takes a new hire to find where "create an order" actually happens. The question is when the price is worth paying.
This table is opinionated on purpose. There is no row that says "it depends on the team's preference". Preference is not an axis. Lifespan, complexity, and team count are.
| Project shape | Recommended approach | What to skip |
|---|---|---|
| Solo prototype, weeks long, will be thrown away | Skip the architecture. Eloquent in the controller. Optimize for "delete this in a month". | Everything below. |
| Small CRUD admin, < 200 LOC of business logic | Framework directly. Reaching for an interface is the signal you have outgrown this row. | Most of it. |
| Internal tool, single team, < 1 year | Hexagonal cut only. One boundary between application and adapters. Service classes (OrderService) carrying the orchestration. |
Use case classes, input/output DTOs, separation between domain and ORM entity. |
| Customer-facing SaaS, post-PMF, ~2-year horizon | Clean-style use cases on top of Hexagonal vocabulary at the edge. Three namespaces. Both cuts. | Aggregates, anti-corruption layers, CQRS, event sourcing — until a second context appears. |
| Long-lived backend service, 2–5 year horizon | The default. Three namespaces, both cuts, domain entity separate from ORM entity. | Tactical DDD as a default. |
| Multi-team service, multiple bounded contexts | Both cuts as the floor. Tactical DDD on top: aggregates, ACLs between contexts, repository per aggregate root. | Nothing. Formality earns out here. |
| Legacy Laravel monolith you have to keep shipping in | Strangler. Pick one bounded context. Apply the default row to it only. Migrate by feature, never big-bang. | Rewriting the parts that already work. |
| One-off batch script, runs once | Procedural PHP in a single file. A Repository interface for a script that runs once is performance art. |
All of it. |
Notice what the table is saying. The Hexagonal cut (ports at the edges) is the cheap one. You almost always want it the moment you have more than one inbound adapter or anything you might want to fake in tests. The Clean cut (a separate App\Domain\ away from App\Application\) is the more expensive one. You pay for it in directory ceremony, and it pays you back when the business logic starts arguing with itself.
A useful way to read the table: lean Hexagonal first, add Clean's inner cut when the application stops being one layer of orchestration and starts having entities that survive use case rewrites.
When to lean which way, in one sentence per situation
The architectures earn their keep at different boundaries. Map the situation to the boundary that hurts the most.
Lean Hexagonal when the pain is at the edge. Multiple inbound adapters (HTTP, queue, CLI) calling the same logic. A framework you suspect you will rip out before the application is decommissioned. A team that argues about Eloquent vs Doctrine in PR reviews. The port-and-adapter vocabulary is sharper than rings at the seam where the application meets the world.
Lean Clean when the pain is in the middle. Business logic with rules that span multiple entities. A domain expert who pushes back on setStatus("cancelled") and insists you call it Cancel(reason). State machines with five or more transitions. Entities that need to outlive any single use case rewrite. The four rings give you a clearer story about what the inside looks like.
Lean both when the codebase will live more than two years. Customer-facing SaaS, internal platforms with multiple teams, anything backing a regulated business. The combined shape (three namespaces, both cuts) is the default and the rest of the table is the exception list.
Skip both when the application is small enough to hold in your head. A two-page admin. A weekend prototype. A script. The cost of the formality is not theoretical — every indirection makes a new hire's first PR slower. Spend the budget on the cases where the dependency rule actually saves you from yourself.
The bits both architectures forbid, no matter which you lean
You can lean either way and still write a codebase that violates both. Four patterns survive cosmetically while breaking the dependency rule semantically:
-
ActiveRecord as the domain object.
Order extends Modelwith aconfirm()method on it is the domain layer in name only. The class drags Eloquent into every test. -
Repository as DAO. An
OrderRepositorythat returns associative arrays or framework records, not domain objects. The interface is inApp\Domain\but the contract isApp\Infrastructure\shaped. -
Service-as-dumping-ground. A 1,200-line
OrderServicewith thirty public methods that each grew their own private helpers. Both architectures forbid this — Clean by name (use cases), Hexagonal by spirit (one verb per behavior). -
Anemic entities surrounded by fat services. Domain objects that are property bags, with all the rules living in
OrderService::confirm($order). The cut is drawn on paper, not in the behavior.
If your codebase has these, neither vocabulary is going to save you. Fix the patterns first; the labels come second.
What this means for your codebase Monday
Open App/ (or wherever your namespace root is) and look at the top-level directories. Count the App\Domain\, App\Application\, App\Infrastructure\ you actually have. Zero, one, two, three? That is your first measurement.
Open three use-case-shaped files and check the use statements at the top. A use case in App\Application\ should only import from App\Domain\ or from its own DTOs and ports. A use Illuminate\Support\Facades\DB; is a leak. A use Doctrine\ORM\EntityManager; is a leak. A use App\Infrastructure\...; is a leak. Count the leaks.
Now open a domain entity. A domain entity should have zero framework imports. No Doctrine attributes unless you have made the conscious mapped-entity trade-off. No Eloquent. No Carbon. No Symfony Validator. If you find framework imports there, that is the cut you still need to make.
You now have two pictures: the cuts you currently have, and the cuts your row in the table recommends. The difference is the work. It might be one afternoon of moving four files and renaming a namespace, or it might be a quarter of strangler refactoring. Either way, it is concrete, and it belongs to your codebase rather than a generic prescription.
The decision was never "which architecture". It is which cuts to make in this codebase, right now, given what the next two years of the product look like.
If this was useful
The book this came from spends six chapters on the synthesis: which cuts you make in PHP 8.3, how Laravel 11 and Symfony 7 each pull against the boundary in their own way, and when a smaller shape is engineering rather than technical debt waiting to compound. The decision table above is the spine of one chapter; the rest works through the patterns both architectures forbid and the migration playbook for legacy services that have already crossed a few of them.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)