- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've read the books. Clean Architecture, Implementing DDD, the Cockburn blog post, half a dozen Laravel-conference talks about hexagonal. You've collected the vocabulary: entities, value objects, aggregates, ports, adapters, use cases, anti-corruption layers, bounded contexts. You've drawn the diagrams. You've argued about repositories versus DAOs in a code review and lost.
And on Monday morning, you open a controller that calls Eloquent directly, and you have no idea whether to be angry about it.
Almost no tutorial leads with the one rule all of that vocabulary is built on. Get it, and most of the other arguments fold into it. Without it, you can name every pattern in the book and still ship an application whose domain imports Illuminate\Database\Eloquent\Model.
The rule is this: source-code dependencies cross layer boundaries in one direction, inward. The domain doesn't know the framework exists. The framework knows the domain. Reverse one arrow on one line in one file, and the architecture starts rotting from that file outward.
Everything below is four shapes of reversed arrow the rule catches, what they cost, and how to make CI shout the moment a new one arrives.
The five-second test
Open any file under App\Domain\ in your current project. Look at the use block at the top. That block tells you in about five seconds whether the file obeys the dependency rule.
If you see any of these namespaces, the rule is broken:
-
Illuminate\— Laravel -
Symfony\— Symfony -
Doctrine\— Doctrine ORM, DBAL, Common -
GuzzleHttp\— HTTP client -
Monolog\— logging -
Predis\,Redis— cache and queue clients -
PhpAmqpLib\— AMQP -
App\Infrastructure\— your own infrastructure layer
You're hunting for any vendor namespace that ships an adapter, a transport, or a framework. The domain shouldn't know they exist. A class called Order should have no idea whether the database is MySQL or whether the HTTP client is Guzzle.
Run the test on three files right now. Most teams find at least one violation in the first file they open. It's what happens when the framework arrives before the architecture does and is generous with its annotations.
Why one rule replaces fifty
Hexagonal has ports and adapters. Clean Architecture has four concentric rings. DDD has aggregates, value objects, repositories, anti-corruption layers, bounded contexts. The vocabulary inflates. Conference talks inflate it further.
Underneath the vocabulary, every one of those styles enforces the same thing: source-code dependencies cross layer boundaries in exactly one direction: inward, toward the domain.
Once you internalize that, the advice you've been collecting collapses into corollaries:
-
"Don't put SQL in the controller." — A controller calling
DB::table()directly is an outer ring depending on an outer ring through a side channel. Route it through a port. -
"Don't extend
Eloquent\Modelfor domain entities." — The base class dragsIlluminate\into the domain via inheritance. Arrow reversed. - "The repository returns domain objects, not rows." — Otherwise the domain would have to import row shapes from the infrastructure to use them. Arrow reversed.
-
"Use cases shouldn't depend on framework requests." —
Illuminate\Http\Requestis an outer-ring type. The use case is inner. Arrow reversed. -
"Don't catch
PDOExceptionin the service layer." — That exception belongs to the database driver. Inner code catching it knows about the driver. Arrow reversed. -
"Domain events shouldn't extend the framework's event base class." — Same shape. Inheritance is a
usestatement with extra steps.
You can keep going. The dependency rule is what turns architecture from a vibe into a use block you can grep.
Four bad-direction examples the rule catches
The rule is most useful when you point it at code and let it pick fights. Here are four shapes you will find in almost every Laravel or Symfony codebase that's older than two years. The fix in each case is the same fix in different clothes: name the port the inner layer needs, move the framework type outside.
1. The Eloquent-as-domain entity
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Illuminate\Database\Eloquent\Model;
final class Order extends Model
{
protected $table = 'orders';
protected $fillable = ['customer_id', 'total_cents'];
public function isOverdue(): bool
{
return $this->created_at->diffInDays(now()) > 30
&& $this->status !== 'paid';
}
}
The five-second test fails on the second line. Illuminate\ is now part of the domain's public surface. Every method Order exposes (save, delete, where, with, magic accessors) comes from the framework. The business behavior (isOverdue) is buried inside an API the domain didn't choose.
What it costs: you can't test isOverdue without booting the framework, because created_at is a Carbon instance the model conjured at hydration time. You can't swap Eloquent for Doctrine without rewriting every consumer. The "domain" is a database row with manners.
The fix: a plain Order in App\Domain\Order\ with the fields it actually owns, and a separate OrderRecord in App\Infrastructure\Persistence\Eloquent\ that knows about Model. A repository at the boundary maps between them. The domain stops importing the framework, and isOverdue becomes a function you can call from a unit test with three lines of arrangement.
2. The use case that takes a framework Request
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use Illuminate\Http\Request;
final class PlaceOrder
{
public function __construct(
private readonly OrderRepository $orders,
) {
}
public function handle(Request $request): Order
{
$order = new Order(
customerId: $request->input('customer_id'),
items: $request->input('items'),
);
$this->orders->save($order);
return $order;
}
}
Reads fine. Works in production. The arrow is wrong.
Illuminate\Http\Request is a framework type. The application layer is now coupled to Laravel's HTTP stack: a CLI worker can't run this use case without faking a Request. A queue listener can't either. Six months from now the team adds a Symfony Messenger listener for the same business action and discovers that PlaceOrder::handle won't take its payload.
The fix is a DTO the application owns:
<?php
declare(strict_types=1);
namespace App\Application\Order;
final readonly class PlaceOrderInput
{
/** @param list<array{sku: string, quantity: int, price_cents: int}> $items */
public function __construct(
public string $customerId,
public array $items,
) {
}
}
PlaceOrder::handle now takes PlaceOrderInput. The controller translates Request into PlaceOrderInput at the edge. The CLI command translates argv into the same PlaceOrderInput. The queue worker translates the message body. Three entry points, one use case, no framework type past the front door.
3. The repository that returns rows
<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface OrderRepository
{
/** @return array<int, array<string, mixed>> */
public function findOverdueForCustomer(string $customerId): array;
}
The interface lives in the domain. The five-second test passes. The shape of the return type doesn't.
array<int, array<string, mixed>> is a row shape. It is what DB::select() and PDO::fetchAll() return. Every caller in the domain now has to know the column names (created_at, total_cents, status) and the casing the database uses. The domain has stopped being the source of truth for its own objects. The database is, and the "repository" is a thin renamer of select * from orders.
This is the difference the book draws hard between a Repository and a DAO. A DAO returns rows. A Repository returns domain objects.
The fix is one return type away:
/** @return list<Order> */
public function findOverdueForCustomer(string $customerId): array;
Or, in PHP 8.3 syntax that the type system actually checks at the boundary:
public function findOverdueForCustomer(string $customerId): OrderCollection;
The adapter is the only place that knows created_at is a column. Inside the domain, you're calling $order->isOverdue() on objects you own. The arrow points the right way again.
4. The service-layer-as-dumping-ground catching driver exceptions
<?php
declare(strict_types=1);
namespace App\Application\Subscription;
use App\Domain\Subscription\Subscription;
use App\Domain\Subscription\SubscriptionRepository;
use PDOException;
final class RenewSubscription
{
public function __construct(
private readonly SubscriptionRepository $subscriptions,
) {
}
public function execute(string $subscriptionId): void
{
try {
$sub = $this->subscriptions->findById($subscriptionId);
$sub->renew();
$this->subscriptions->save($sub);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'Deadlock found')) {
throw new RenewalFailed('database deadlock, retry later');
}
throw $e;
}
}
}
The use case knows what a deadlock is. The use case knows the database driver throws PDOException. The use case knows the error message string the driver uses to say "deadlock."
That is three pieces of infrastructure knowledge sitting inside a class whose job is to express a business action. If the team migrates to PostgreSQL, the deadlock detection breaks silently. If the team adds a retry middleware at the persistence layer, the use case re-throws something already handled.
The fix is to translate at the boundary. The adapter catches PDOException, decides whether it represents a DeadlockDetected, a UniqueConstraintViolated, or a generic PersistenceFailed, and throws a domain-shaped exception the use case can reason about without knowing what a PDOException is. The driver name stops appearing inside the application layer entirely.
That last move, domain-shaped exceptions translated at the adapter, is what makes "swap the database" a real option rather than a slogan. The use case talks in DeadlockDetected whether the underlying engine is MySQL, PostgreSQL, or SQLite.
Make the rule run on every commit
A rule that lives only in a chapter is a rule the next pull request will break. The dependency rule has to run on every commit, and that means a static check.
PHP has two solid tools. Either one becomes a composer require --dev and a step in CI.
deptrac.yaml for the three-layer shape:
deptrac:
paths:
- ./src
layers:
- name: Domain
collectors:
- type: directory
value: src/Domain/.*
- name: Application
collectors:
- type: directory
value: src/Application/.*
- name: Infrastructure
collectors:
- type: directory
value: src/Infrastructure/.*
ruleset:
Domain: ~
Application:
- Domain
Infrastructure:
- Domain
- Application
Read the ruleset block top to bottom. Domain depends on nothing. Application depends on Domain only. Infrastructure depends on both. That's the rule, in twelve lines of YAML.
Run it:
vendor/bin/deptrac analyse
In a healthy project, you see zero violations. In every project that runs this for the first time, you see a list. The list is honest. It's the dependency-rule debt the codebase has been accumulating, file by file, since the architecture started drifting. Each entry is a use statement pointing the wrong way.
Don't fix the list on day one. Measure it. Print the number. Put it somewhere visible. Track whether it goes up or down per sprint. A team that knows its violation count has already won most of the argument, because the number isn't a feeling anymore.
PHPArkitect does the same job with fluent PHP configuration if YAML annoys you. Pick the one your team will keep running. The tool that gets switched off after a noisy week is the one you shouldn't have chosen.
The exception worth naming openly
There's exactly one place where this gets pragmatic, and pretending otherwise is dishonest.
Some teams put Doctrine attributes directly on the domain entity:
#[ORM\Entity]
#[ORM\Table(name: 'orders')]
final class Order { /* ... */ }
The five-second test fails. Doctrine\ is in App\Domain\. The rule is broken.
The strict answer is the recommended one: separate the domain Order from the persistence OrderRecord, map between them at the repository. The pragmatic answer is that some teams don't have the budget for that on day one, the mapping is one-to-one, and a hand-written translation layer would double the line count without paying it back for a year.
If a team chooses the trade-off, the requirement is to name it. A docblock on the entity, an architecture decision record next to the code, an exclusion line in deptrac.yaml: somewhere a future engineer can't miss. The dangerous leak is the undocumented one. A documented exception is an exception. An undocumented one becomes a precedent, and two years later nobody remembers it was supposed to be temporary.
What the rule buys you
Half the architecture posts you've ever read become mechanical once the dependency rule is locked. "Where does this code go?" is answered by a use block. "Can we swap this?" is a one-file change. "Port or adapter?" comes down to which way the import arrow points.
You don't have to know what an aggregate root is to ship a maintainable PHP application. You have to know that the domain doesn't import the framework. Once that's true on every commit, the rest of the vocabulary becomes optional sophistication you can reach for when the domain actually rewards it.
The CI step is what makes it stick. Without that, the rule is a wish.
If this was useful
The full treatment — Repository versus DAO, the four anti-patterns both architectures forbid, the synthesis of Hexagonal and Clean for PHP, and the strangler-pattern migration playbook for Laravel and Symfony services already in production — is what Decoupled PHP is for. It walks the dependency rule from the first chapter all the way through to a full reference application with HTTP, CLI, queue workers, Doctrine, Guzzle, and tests that don't touch a database.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)