- 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
You've seen the picture. Four concentric rings, the words Entities, Use Cases, Interface Adapters, Frameworks & Drivers arranged like a target, arrows pointing inward. It's on the cover of Robert C. Martin's Clean Architecture, in every architecture talk on YouTube, and pinned in someone's Slack right now.
The picture is beautiful. The problem is the picture doesn't tell you where to put PlaceOrderController.php. It doesn't tell you whether App\Domain\Order should hold the Doctrine attributes or not. It doesn't tell you what to do when Laravel's Request object shows up in a method signature.
This post does the translation. Four rings on the whiteboard, mapped to PHP namespaces and a directory tree you can paste into a src/ folder today.
The four rings, restated for PHP people
The rings aren't folder names. They're a rule about which way the arrows point: source-code dependencies cross ring boundaries inward, never outward. The outer rings know about the inner rings. The inner rings have never heard of the outer rings.
Reading from the center outward:
Ring 1 — Entities. Things the business cares about. Order, Customer, Invoice, Money. They encode rules of the business itself, not rules of this particular application. An Order refuses to transition from shipped back to pending. That's true whether the system is a website, a CLI, or a queue worker. Plain PHP. No Doctrine, no Eloquent, no framework imports.
Ring 2 — Use Cases. What this application does. PlaceOrder, CancelOrder, RefundPayment. One verb per class. Orchestrates entities and ports to deliver one business operation. Knows nothing about HTTP, CLI, queues, or which database is behind the repository.
Ring 3 — Interface Adapters. Translation. A controller takes an HTTP request and shapes it into the input the use case expects. A repository implementation takes a domain Order and shapes it into rows for Doctrine. The adapter ring does shape-shifting, not business logic.
Ring 4 — Frameworks and Drivers. Laravel, Symfony, Doctrine, Guzzle, PHP-FPM, the database, the message broker. The volatile, version-churning edge of the system. The things you have no control over and don't want to.
The promise: when Doctrine ships a major version, only ring 4 changes. Moving a service from Laravel 11 to Symfony 7 leaves rings 1 and 2 untouched.
Three namespaces, four rings
The four rings collapse cleanly into three folders on disk. Most PHP teams will end up here:
src/
Domain/
Order/
Order.php
OrderId.php
OrderStatus.php
OrderRepository.php // port (interface)
OrderAlreadyShipped.php // domain exception
Customer/
Customer.php
CustomerId.php
Shared/
Money.php
Application/
Order/
PlaceOrder.php // use case
PlaceOrderInput.php // input DTO
PlaceOrderOutput.php // output DTO
CancelOrder.php
Port/
Clock.php // port (interface)
Infrastructure/
Http/
Controller/
PlaceOrderController.php
Cli/
PlaceOrderCommand.php
Persistence/
Doctrine/
DoctrineOrderRepository.php
Clock/
SystemClock.php
App\Domain is ring 1, plus the repository port interfaces written in the domain's own language. App\Application is ring 2: use cases, DTOs, and application-level ports. App\Infrastructure collapses rings 3 and 4 because in a real project the controller class and the framework that hosts it are not separable. PlaceOrderController lives in the same file as the framework's Request and Response types. Splitting "the controller class" from "the framework's HTTP types" into two directories produces a longer path and a fiction about purity that the IDE will not honor.
Three folders. The dependency rule still applies at the seam between rings 3 and 4 inside Infrastructure/.
Ring 1 in PHP: an entity that knows nothing
Here's App\Domain\Order\Order. It encodes what an order is. It does not encode what the system does when an order is placed.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Customer\CustomerId;
use App\Domain\Shared\Money;
use DateTimeImmutable;
final class Order
{
/** @var list<OrderLine> */
private array $lines = [];
private OrderStatus $status = OrderStatus::Pending;
public function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
public readonly DateTimeImmutable $placedAt,
) {}
public function addLine(OrderLine $line): void
{
if ($this->status !== OrderStatus::Pending) {
throw new OrderAlreadyShipped(
'Cannot add lines after shipment.'
);
}
$this->lines[] = $line;
}
public function total(): Money
{
return array_reduce(
$this->lines,
fn(Money $sum, OrderLine $l) => $sum->add($l->subtotal()),
Money::zero('EUR'),
);
}
public function markShipped(): void
{
if ($this->status === OrderStatus::Shipped) {
return;
}
$this->status = OrderStatus::Shipped;
}
}
Notice what's missing. No extends Model, no #[ORM\Entity], no Illuminate\ or Symfony\ use statements. The only imports are other ring-1 types. Drop this file into a project whose composer.json requires only "php": "^8.3" and it parses and runs.
That's the test. If Order.php can stand alone with PHP and nothing else, ring 1 is honest. The day someone adds use Illuminate\Database\Eloquent\Model; at the top, ring 1 is leaking and the dependency rule is broken.
Ring 1, continued: the repository port
The OrderRepository interface lives in the domain too, even though every implementation will live in Infrastructure/. The reason is the dependency rule. The use case in ring 2 needs to ask for an order. It does that through an interface. That interface has to live in a ring the use case is allowed to import, which means ring 1 or ring 2, never ring 3 or 4.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface OrderRepository
{
public function find(OrderId $id): ?Order;
public function save(Order $order): void;
}
The interface is named for what the domain needs, not for the storage technology behind it. OrderRepository, not DoctrineOrderTable. The domain doesn't know there will be a database. The domain doesn't know there could be a database. From the domain's view, somewhere out there is something that can save and load orders. Who and how is somebody else's problem.
Ring 2 in PHP: a use case as a single verb
A use case is one application operation. One verb. One class. One public method.
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Application\Port\Clock;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
final class PlaceOrder
{
public function __construct(
private readonly OrderRepository $orders,
private readonly Clock $clock,
) {}
public function execute(PlaceOrderInput $in): PlaceOrderOutput
{
$order = new Order(
id: OrderId::new(),
customerId: $in->customerId,
placedAt: $this->clock->now(),
);
foreach ($in->lines as $line) {
$order->addLine($line);
}
$this->orders->save($order);
return new PlaceOrderOutput(
orderId: $order->id,
totalCents: $order->total()->cents,
);
}
}
This class is testable with three fakes and zero framework boot. No php artisan, no bin/console, no Doctrine bootstrap, no in-memory SQLite. The unit test that exercises PlaceOrder runs in single-digit milliseconds.
If your use-case test needs the framework to boot, ring 2 has been contaminated and you're paying for that contamination on every test run for the rest of the project's life.
Ring 3 in PHP: the controller is an adapter
The controller's job is translation. HTTP in, DTO in. DTO out, HTTP out. Business logic in between is a smell.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller;
use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\OrderLine;
use App\Domain\Shared\Money;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class PlaceOrderController
{
public function __construct(
private readonly PlaceOrder $useCase,
) {}
#[Route('/orders', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$body = json_decode($request->getContent(), true);
$input = new PlaceOrderInput(
customerId: new CustomerId($body['customer_id']),
lines: array_map(
fn(array $l) => new OrderLine(
sku: $l['sku'],
quantity: $l['quantity'],
unitPrice: Money::ofCents(
$l['unit_price_cents'],
'EUR',
),
),
$body['lines'],
),
);
$output = $this->useCase->execute($input);
return new JsonResponse([
'order_id' => (string) $output->orderId,
'total_cents' => $output->totalCents,
], status: 201);
}
}
Read it carefully. The controller parses JSON, builds an input DTO, calls the use case, and serializes the output. No business rules anywhere in the class.
Swap Symfony for Laravel and the diff is in three lines: the #[Route] attribute, the Request type-hint, and the JsonResponse return type. The use case underneath does not change. The domain underneath that does not change either.
Rings 3 and 4 in PHP: where the framework finally shows up
Out at the edge, a single class straddles rings 3 and 4: Doctrine (ring 4) reached through an adapter (ring 3) that implements the OrderRepository port from ring 1.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use Doctrine\DBAL\Connection;
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(
private readonly Connection $db,
) {}
public function find(OrderId $id): ?Order
{
$row = $this->db->fetchAssociative(
'SELECT id, customer_id, placed_at FROM orders WHERE id = ?',
[(string) $id],
);
if ($row === false) {
return null;
}
return OrderMapper::toDomain($row);
}
public function save(Order $order): void
{
$this->db->executeStatement(
'INSERT INTO orders (id, customer_id, placed_at, total_cents)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET total_cents = EXCLUDED.total_cents',
[
(string) $order->id,
(string) $order->customerId,
$order->placedAt->format('c'),
$order->total()->cents,
],
);
}
}
This class imports Doctrine. The class it implements (OrderRepository) does not. The arrow points inward: Infrastructure → Domain. The domain has no idea Doctrine exists. Replace it with a PdoOrderRepository, a DynamoDbOrderRepository, or an InMemoryOrderRepository for tests, and the change is contained to one file in one folder.
That's the payoff. The whole onion drawing exists to make this property true.
The dependency rule, mechanized
A rule that depends on every reviewer remembering it is not a rule. It's a hope. Enforce the dependency rule with Deptrac or PHPArkitect in CI, with a config like this:
# deptrac.yaml
deptrac:
paths:
- ./src
layers:
- name: Domain
collectors:
- { type: classLike, value: ^App\\Domain\\.* }
- name: Application
collectors:
- { type: classLike, value: ^App\\Application\\.* }
- name: Infrastructure
collectors:
- { type: classLike, value: ^App\\Infrastructure\\.* }
ruleset:
Domain: []
Application:
- Domain
Infrastructure:
- Domain
- Application
Domain depends on nothing. Application may depend on Domain. Infrastructure may depend on both. Anything else is a build failure. Push a controller import into your domain and CI fails before review.
A test on your current codebase
Open a feature folder in your current project. Sort every file into three buckets.
Bucket A — pure PHP. Loadable by Composer with "require": { "php": "^8.3" } and nothing else. Imports are PHP built-ins plus your own namespaces. Open each file. Read the use statements. Verify.
Bucket B — application files contaminated by the framework. A use case that imports Illuminate\Support\Facades\DB. A "service" that calls Cache::remember(...). A class extending Symfony\Component\Console\Command\Command but containing business logic inside execute() instead of delegating to a use case class.
Bucket C — framework glue. Routes, controllers that do nothing but call other code, command classes, event listeners registered by attribute, service provider configuration.
Write the three counts down. A healthy application has bucket A as the largest: domain plus use cases, in pure PHP, no framework imports below the controller layer. Bucket C is smaller and lives entirely in Infrastructure/. Bucket B should be small or empty. Every file in bucket B is a refactor candidate: pull the business logic into a use case in bucket A, leave a thin adapter in bucket C.
If bucket B is the largest, the framework is the application.
What the picture is, and isn't
The four rings are a thinking tool. The dependency rule is the law. The directories are tactics that follow from both.
You don't need four folders. You probably don't even need three if the application is small enough. What you need is the property that swapping Doctrine for PDO, or Laravel for Symfony, or your HTTP framework for a Slack-bot adapter, doesn't ripple all the way into the class that knows what an order is.
If that property holds, the picture has done its job. Without it, no amount of App\InterfaceAdapters\Presenters\OrderPlacementSuccessPresenterFactory will save you.
If this was useful
The full PHP application, with every ring built out, every port and adapter wired, and a strangler-pattern migration from a legacy Laravel service into this shape, is the spine of Decoupled PHP. It walks the four rings from a single-file CRUD up to a production service with HTTP, CLI, queue workers, Doctrine, an external payment gateway, and a test suite that runs without booting the framework.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)