DEV Community

Cover image for Hexagonal Architecture in PHP, Without the Astronaut Talk
Gabriel Anhaia
Gabriel Anhaia

Posted on

Hexagonal Architecture in PHP, Without the Astronaut Talk


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.

Three rings: domain in the middle, ports as a contract layer, adapters outside. Arrows point inward only.

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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']),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

A use case in the middle. Three arrows reach into it: an HTTP controller, a CLI command, a queue worker. Two arrows reach out from it: a database adapter, an email adapter. All adapters point at the use case; none of them are imported by it.

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Service folder
  • IOrderRepository

You do need these:

  • A Domain folder whose files import nothing from the framework
  • An Application folder where use cases live
  • An Infrastructure folder 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 use statement 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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)