- 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 opened a tutorial on hexagonal architecture. The first paragraph mentioned bounded contexts. The first diagram had eight arrows and a hexagon with four labels you didn't recognise. The first code sample defined an AbstractAggregateRoot<T> and a DomainEventDispatcher. You closed the tab.
You weren't looking for a reading list. You were looking for where the SQL goes.
That instinct is correct. Hexagonal architecture is a small idea wrapped in a large vocabulary, and most of the vocabulary is borrowed from Java enterprise circa 2005. You can skip almost all of it.
Four words do the work: ports, adapters, use cases, the dependency rule. One example, plain PHP 8.3, no words that need a glossary. By the end, you'll be able to read a hexagonal PHP codebase without flinching, and you'll know which folder a new file belongs in.
The whole idea, in one sentence
Your business code talks to the outside world through interfaces it owns. Concrete things (databases, HTTP clients, queue drivers, mailers) live on the outside and implement those interfaces.
That's it.
The "hexagon" is a drawing. It has six sides because Alistair Cockburn drew it with six sides in the mid-2000s. It could have been a circle. The shape is not the point. The point is the direction of arrows: nothing on the inside knows anything about the outside. The outside knows everything about the inside, because it has to plug into it.
If you've ever heard "the framework should be a detail," this is what people mean. Laravel, Symfony, Doctrine, Guzzle: they are details. They sit outside. Your business code sits inside and does not care which of them is installed today.
The vocabulary, translated
Four words do all the work. Here they are in plain English first, then in PHP.
Domain. The part of your application that would still make sense if Laravel and Symfony both got hit by a bus. Your Order class with its total() method. Your Customer with their tier. The rules a product manager would describe out loud. In PHP, this is a folder of plain classes. No extends Model. No Doctrine attributes. No framework imports at the top of the file.
Port. An interface. The literal interface keyword. It describes something the domain needs but doesn't want to do itself. "Save this order somewhere." "Send this email." "Charge this card." The interface lives next to the domain. It speaks the domain's language. It does not mention SQL, HTTP, or any vendor name.
Adapter. A class that implements a port. DoctrineOrderRepository implements OrderRepository. SymfonyMailerEmailSender implements EmailSender. The adapter knows about Doctrine. The port doesn't. The adapter translates between the domain's vocabulary ("save this order") and the framework's vocabulary ("INSERT INTO orders").
Use case. One class, one business verb. PlaceOrder. CancelSubscription. RefundPayment. It receives an input, talks to the domain, talks to ports, returns an output. It does not know what HTTP is. It does not know what a queue is. It is the thing every entry point calls: a controller, a CLI command, a queue worker.
Those four words. That is the whole vocabulary. Anyone who adds aggregate root, value object, anti-corruption layer, bounded context before you can write your first port is selling Domain-Driven Design. That's a separate thing; adopt it later, when the domain gets rich enough to need it.
A worked example: placing an order
Let's build the smallest hexagonal slice that does something real. A customer sends a cart, the application stores an order with a computed total, and an email goes out. Three operations, four classes, one interface for each thing that touches the outside world.
Here's the folder layout you're aiming for:
src/
├── Domain/
│ └── Order/
│ ├── Order.php
│ └── OrderRepository.php # port
├── Application/
│ ├── Order/
│ │ ├── PlaceOrder.php # use case
│ │ ├── PlaceOrderInput.php
│ │ └── PlaceOrderOutput.php
│ └── Notification/
│ ├── EmailSender.php # port
│ └── EmailMessage.php
└── Infrastructure/
├── Persistence/
│ └── DoctrineOrderRepository.php # adapter
├── Notification/
│ └── SymfonyEmailSender.php # adapter
└── Http/
└── PlaceOrderController.php # adapter
Three top-level folders. Domain, Application, Infrastructure. That's the whole layout. No Services/, no Contracts/, no Helpers/. Resist the urge.
The domain object
<?php
declare(strict_types=1);
namespace App\Domain\Order;
final class Order
{
public function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly array $items,
public readonly int $totalCents,
public readonly \DateTimeImmutable $createdAt,
) {}
public static function totalFor(array $items): int
{
$total = 0;
foreach ($items as $item) {
$total += $item['priceCents'] * $item['quantity'];
}
return $total;
}
}
A plain class. Readonly properties. One small piece of behavior. No extends Model. No Doctrine annotations. The five-second test on the use block: only DateTimeImmutable from PHP's standard library. Nothing from the framework, nothing from a vendor. This is what "the domain at the center" means.
The port
<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface OrderRepository
{
public function save(Order $order): void;
public function find(string $id): ?Order;
}
Two methods. Both speak about orders, not rows. The return type is ?Order, not array, not stdClass. The parameter is the domain object, not an Eloquent model or a Doctrine entity.
Notice the name: OrderRepository. Not IOrderRepository. PHP doesn't need the prefix; the IDE already shows you what's an interface and what isn't. Save the clean name for the interface; the adapter pays the qualifier tax.
The use case (which is also a port)
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Application\Notification\EmailMessage;
use App\Application\Notification\EmailSender;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
final readonly class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private EmailSender $email,
) {}
public function execute(PlaceOrderInput $input): PlaceOrderOutput
{
$order = new Order(
id: bin2hex(random_bytes(8)),
customerId: $input->customerId,
items: $input->items,
totalCents: Order::totalFor($input->items),
createdAt: new \DateTimeImmutable(),
);
$this->orders->save($order);
$this->email->send(new EmailMessage(
to: $input->customerEmail,
subject: 'Order placed',
body: "Your order {$order->id} is in.",
));
return new PlaceOrderOutput(
orderId: $order->id,
totalCents: $order->totalCents,
);
}
}
Read the use statements at the top. Four imports, all from App\Domain\ or App\Application\. Nothing from Symfony\, nothing from Illuminate\, nothing from Doctrine\. That is what the dependency rule looks like applied: the inside doesn't know the outside exists.
PlaceOrderInput and PlaceOrderOutput are readonly DTOs with the fields the use case needs. They're not the HTTP request and they're not the response body. The HTTP layer will translate to and from them in a second.
The outbound adapter
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use Doctrine\DBAL\Connection;
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(
private readonly Connection $db,
) {}
public function save(Order $order): void
{
$this->db->insert('orders', [
'id' => $order->id,
'customer_id' => $order->customerId,
'total_cents' => $order->totalCents,
'created_at' => $order->createdAt->format('Y-m-d H:i:s'),
]);
}
public function find(string $id): ?Order
{
$row = $this->db->fetchAssociative(
'SELECT * FROM orders WHERE id = ?',
[$id]
);
if ($row === false) {
return null;
}
return new Order(
id: $row['id'],
customerId: $row['customer_id'],
items: [],
totalCents: (int) $row['total_cents'],
createdAt: new \DateTimeImmutable($row['created_at']),
);
}
}
The adapter imports everything: the port, the domain class, the Doctrine connection. That's correct. Adapters are the layer that knows both languages, because translating between them is their job. Swap Doctrine for raw PDO, Eloquent, or DynamoDB and only this file changes. The use case doesn't notice.
The inbound adapter
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http;
use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
final class PlaceOrderController
{
public function __construct(
private readonly PlaceOrder $placeOrder,
) {}
public function __invoke(Request $request): JsonResponse
{
$body = json_decode($request->getContent(), true);
$output = $this->placeOrder->execute(new PlaceOrderInput(
customerId: $body['customer_id'],
customerEmail: $body['customer_email'],
items: $body['items'],
));
return new JsonResponse([
'order_id' => $output->orderId,
'total_cents' => $output->totalCents,
], 201);
}
}
The controller is also an adapter. It speaks HTTP on one side and the application's input/output DTOs on the other. Three things happen: parse the JSON, call the use case, translate the result. No business logic lives here, because the controller is replaceable. Add a CLI command tomorrow that places an order from a file, and it calls the same PlaceOrder::execute() with the same PlaceOrderInput. The use case doesn't know whether you came in through HTTP, a queue, or a cron job.
The dependency rule, in five seconds
If you remember nothing else from this post, remember this. Open any file under src/Domain/ or src/Application/. Look at the use block at the top. If it imports anything from these namespaces, your architecture is leaking:
-
Illuminate\(Laravel) -
Symfony\(Symfony) -
Doctrine\(Doctrine) -
GuzzleHttp\(Guzzle) -
Monolog\(Monolog) -
App\Infrastructure\(your own infrastructure folder)
That's the whole rule. Dependencies point inward. The domain knows nothing. The application knows the domain. The infrastructure knows both.
If you want a tool to keep you honest, drop in Deptrac or PHPArkitect and add one line to CI. Either will fail a build that imports the wrong way. The five-second test does the job in code review; the static check does it in the pipeline.
Tests stop needing a database
Here's the payoff. Test the use case with a fake repository and a fake email sender. No container, no database, no MailHog, no fixtures.
<?php
declare(strict_types=1);
namespace Tests\Application\Order;
use App\Application\Notification\EmailMessage;
use App\Application\Notification\EmailSender;
use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use PHPUnit\Framework\TestCase;
final class InMemoryOrders implements OrderRepository
{
public array $saved = [];
public function save(Order $order): void
{
$this->saved[] = $order;
}
public function find(string $id): ?Order
{
foreach ($this->saved as $o) {
if ($o->id === $id) return $o;
}
return null;
}
}
final class SpyEmails implements EmailSender
{
public array $sent = [];
public function send(EmailMessage $message): void
{
$this->sent[] = $message;
}
}
final class PlaceOrderTest extends TestCase
{
public function test_it_saves_an_order_and_emails_the_customer(): void
{
$orders = new InMemoryOrders();
$emails = new SpyEmails();
$useCase = new PlaceOrder($orders, $emails);
$output = $useCase->execute(new PlaceOrderInput(
customerId: 'cust-1',
customerEmail: 'customer@example.com',
items: [['priceCents' => 1500, 'quantity' => 2]],
));
$this->assertSame(3000, $output->totalCents);
$this->assertCount(1, $orders->saved);
$this->assertCount(1, $emails->sent);
}
}
The fakes are six lines each. There is no mocking library. No Mockery::mock(MailerInterface::class)->shouldReceive('send'). The test runs in milliseconds. The same test would have taken a Docker container, a database fixture, and a real SMTP catcher under the version that hardcoded MailerInterface.
When tests are this cheap, you write more of them. That's the win, and it shows up on day one.
What to drop, what to keep
You don't need any of these to call your codebase hexagonal:
- Aggregate roots
- Value objects for every primitive
- Ubiquitous language
- Bounded contexts
- Domain events
- A
Servicefolder IOrderRepository
You do need these:
- A
Domainfolder whose files import nothing from the framework - An
Applicationfolder where use cases live - An
Infrastructurefolder where everything that knows Doctrine, Symfony, or Laravel lives - One interface per outbound thing the use case needs to do, owned by the inner layer
- One static check in CI that fails when a
usestatement points the wrong way
Day one of a new project, that's enough. The aggregates and the domain events and the ubiquitous language can show up later, in the subscription module where the business actually has five rules about cancellation. The checkout module that turns a cart into a row in a table doesn't need them. Hexagonal earns its keep on day one. The Java enterprise vocabulary earns its keep on the day someone tells you the third rule about pause and resume.
The folder layout above is the spine. Everything else is taste.
If this was useful
Decoupled PHP is the long version of this post: thirty chapters that walk the same hexagon from the toy checkout you just read up to a production order service with Doctrine, Symfony Messenger, transactions across layers, and a real migration playbook for a legacy Laravel codebase. Same plain-English voice, same PHP 8.3, no Java vocabulary smuggled in. If hexagonal finally clicked for you here, the book is where you make it stick.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)