DEV Community

Cover image for The Complete Hexagonal Service in PHP: HTTP, CLI, Queue, DB, Tests
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Complete Hexagonal Service in PHP: HTTP, CLI, Queue, DB, Tests


You've read the hexagonal posts with their diagrams of hexagons and arrows, the toy snippet with one port and one adapter, and the closing line that says now go build it.

Then you sit in front of a real PHP project and the questions start. Where does the controller go? Does the queue worker call the use case directly, or through the controller? Where do you open the database transaction?

This post is the answer. One service, every layer, real PHP 8.3, every wire visible. The full code lives in a public examples repo (linked at the bottom). Clone it to read what the markdown can only point at.

The service is a small e-commerce order backend. Three doors in: HTTP, CLI, queue worker. Two outbound adapters carry the work out — MySQL via Doctrine and a payment gateway via Guzzle. One event bus via the outbox pattern, draining to RabbitMQ. One use case (PlaceOrder) reachable from all three inbound adapters with zero duplication. Tests at three levels: unit, contract, integration.

The directory tree

The layout is three top-level namespaces, mapped to three directories under src/:

complete-service/
  composer.json
  docker-compose.yml
  public/index.php
  bin/console
  src/
    Domain/                     # pure PHP, zero framework imports
      Order/
      Customer/
      Shared/
    Application/                # use cases + the ports they consume
      Order/PlaceOrder.php
      Order/CancelOrder.php
      Port/
        OrderRepository.php
        CustomerRepository.php
        PaymentGateway.php
        EventBus.php
        Clock.php
    Infrastructure/             # adapters — Doctrine, Guzzle, AMQP, Symfony
      Persistence/Doctrine/
      Persistence/InMemory/
      Http/Controller/
      Cli/
      Messaging/Amqp/
      Payment/
      Clock/
    Bootstrap/Container.php
  tests/
    Unit/
    Contract/
    Integration/
Enter fullscreen mode Exit fullscreen mode

The Dependency Rule is a directory walk. Domain imports nothing. Application imports Domain. Infrastructure imports both, and is the only layer allowed to know about Doctrine, Symfony, Guzzle, or AMQP. One CI check guards the import graph: grep src/Domain/ for use Doctrine, use Symfony, use GuzzleHttp, and fail the build if any of them appear. Static-analysis tools (deptrac, phparkitect) do the same job with more nuance.

Full service overview — three inbound adapters feed one use case through ports; three outbound adapters carry the work to MySQL, a payment gateway, and RabbitMQ

The domain entity

src/Domain/Order/Order.php is the file the business pays for. It extends nothing. It imports nothing outside Domain/. It carries invariants and behavior, not getters and setters.

<?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 LineItem[] */
    private array $items;

    /** @var DomainEvent[] */
    private array $pendingEvents = [];

    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        array $items,
        private OrderStatus $status,
        private readonly DateTimeImmutable $placedAt,
    ) {
        if ($items === []) {
            throw new InvalidOrder('order must have at least one item');
        }
        $currency = $items[0]->price->currency;
        foreach ($items as $item) {
            if ($item->price->currency !== $currency) {
                throw new InvalidOrder('mixed currencies in one order');
            }
        }
        $this->items = $items;
    }

    public static function place(
        OrderId $id,
        CustomerId $customerId,
        array $items,
        DateTimeImmutable $now,
    ): self {
        $order = new self($id, $customerId, $items, OrderStatus::Placed, $now);
        $order->pendingEvents[] = new OrderPlaced($id, $customerId, $now);
        return $order;
    }

    public static function restore(
        OrderId $id,
        CustomerId $customerId,
        array $items,
        OrderStatus $status,
        DateTimeImmutable $placedAt,
    ): self {
        return new self($id, $customerId, $items, $status, $placedAt);
    }

    public function cancel(DateTimeImmutable $now): void
    {
        if ($this->status === OrderStatus::Shipped) {
            throw new InvalidOrder('cannot cancel a shipped order');
        }
        $this->status = OrderStatus::Cancelled;
        $this->pendingEvents[] = new OrderCancelled($this->id, $now);
    }

    public function total(): Money
    {
        $total = Money::zero($this->items[0]->price->currency);
        foreach ($this->items as $item) {
            $total = $total->plus($item->lineTotal());
        }
        return $total;
    }

    public function id(): OrderId { return $this->id; }
    public function status(): OrderStatus { return $this->status; }

    /** @return DomainEvent[] */
    public function releaseEvents(): array
    {
        $events = $this->pendingEvents;
        $this->pendingEvents = [];
        return $events;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two named constructors. place is for new orders; restore is for rehydration from storage. The mapping layer uses restore, never new, so the Doctrine adapter does not have to know about the placement invariants.

Money is a value object, OrderStatus an enum, LineItem a tiny readonly class with sku, quantity, and price. They live next to Order in src/Domain/Order/ and src/Domain/Shared/. None import from outside the Domain namespace.

The ports

src/Application/Port/ is where the use case states its requirements as interfaces, expressed in domain language.

<?php

declare(strict_types=1);

namespace App\Application\Port;

use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Shared\Money;
use DateTimeImmutable;

interface OrderRepository
{
    public function save(Order $order): void;
    public function findById(OrderId $id): ?Order;
}

interface CustomerRepository
{
    public function findById(CustomerId $id): ?Customer;
}

interface PaymentGateway
{
    public function charge(
        CustomerId $customerId,
        Money $amount,
        string $idempotencyKey,
    ): PaymentReceipt;
}

interface EventBus
{
    public function publishAll(array $events): void;
}

interface Clock
{
    public function now(): DateTimeImmutable;
}
Enter fullscreen mode Exit fullscreen mode

Five interfaces. The signatures are stated in domain types (Order, CustomerId, Money), not in framework types. There is no Request, no EloquentBuilder, no EntityManager, no ClientInterface. The use case can read these and reason about them without opening a single vendor file.

The use case

src/Application/Order/PlaceOrder.php is the orchestration. It is the only file that knows the sequence: load customer, build order, charge, save, publish events.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Application\Port\Clock;
use App\Application\Port\CustomerRepository;
use App\Application\Port\EventBus;
use App\Application\Port\OrderRepository;
use App\Application\Port\PaymentGateway;
use App\Domain\Customer\CustomerId;
use App\Domain\Customer\CustomerNotFound;
use App\Domain\Order\LineItem;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Shared\Money;

final readonly class PlaceOrder
{
    public function __construct(
        private CustomerRepository $customers,
        private OrderRepository $orders,
        private PaymentGateway $payments,
        private EventBus $events,
        private Clock $clock,
    ) {}

    public function execute(PlaceOrderInput $in): PlaceOrderOutput
    {
        $customerId = new CustomerId($in->customerId);
        $customer = $this->customers->findById($customerId);
        if ($customer === null) {
            throw new CustomerNotFound($customerId);
        }

        $items = [];
        foreach ($in->items as $line) {
            $items[] = new LineItem(
                sku: $line->sku,
                quantity: $line->quantity,
                price: new Money($line->priceCents, $in->currency),
            );
        }

        $order = Order::place(
            id: OrderId::generate(),
            customerId: $customerId,
            items: $items,
            now: $this->clock->now(),
        );

        $this->payments->charge(
            $customerId,
            $order->total(),
            $in->idempotencyKey,
        );

        $this->orders->save($order);
        $this->events->publishAll($order->releaseEvents());

        return new PlaceOrderOutput(
            orderId: $order->id()->value,
            totalCents: $order->total()->amountInMinorUnits,
            currency: $in->currency,
            status: $order->status()->value,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

PlaceOrderInput and PlaceOrderOutput are plain DTOs. The use case has no framework imports. There is no DB::transaction() call, no session opened, no request object touched. If you printed this file and showed it to a senior engineer who had never used Symfony or Laravel, they would still know what it does.

The transaction is not inside the use case. A TransactionalDecorator<PlaceOrder> wraps it at wiring time, opens a Doctrine transaction before execute, commits on success, and rolls back on a thrown exception. The decorator lives in Infrastructure/. The use case stays clean.

Three inbound adapters

Same use case, three doors.

HTTP controller

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Controller;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
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 $placeOrder) {}

    #[Route('/orders', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $payload = json_decode(
            $request->getContent(),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );

        $input = PlaceOrderInput::fromArray($payload);
        $output = $this->placeOrder->execute($input);

        return new JsonResponse($output, 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

Roughly twenty lines: parse, call, serialize. No business logic, no SQL, no if on order rules. The controller does not know there is a payment gateway behind the use case. It just knows the use case can throw, and the framework's exception listener maps domain exceptions to HTTP statuses elsewhere.

CLI command

<?php

declare(strict_types=1);

namespace App\Infrastructure\Cli;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'orders:place')]
final class PlaceOrderCommand extends Command
{
    public function __construct(private readonly PlaceOrder $placeOrder)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $in, OutputInterface $out): int
    {
        $payload = json_decode(
            (string) file_get_contents($in->getArgument('file')),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );

        $output = $this->placeOrder->execute(
            PlaceOrderInput::fromArray($payload),
        );

        $out->writeln(json_encode($output, JSON_PRETTY_PRINT));
        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Same DTO, same use case, different shell. An operator running a backfill from a JSON file gets the exact rules a customer hitting the API gets, because there is one place those rules live.

Queue worker

<?php

declare(strict_types=1);

namespace App\Infrastructure\Messaging\Amqp;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use PhpAmqpLib\Channel\AMQPChannel;
use PhpAmqpLib\Message\AMQPMessage;
use Psr\Log\LoggerInterface;

final class PlaceOrderConsumer
{
    public function __construct(
        private readonly PlaceOrder $placeOrder,
        private readonly LoggerInterface $logger,
    ) {}

    public function handle(AMQPMessage $message, AMQPChannel $channel): void
    {
        try {
            $payload = json_decode(
                $message->body,
                associative: true,
                flags: JSON_THROW_ON_ERROR,
            );
            $this->placeOrder->execute(PlaceOrderInput::fromArray($payload));
            $channel->basic_ack($message->getDeliveryTag());
        } catch (\Throwable $e) {
            $this->logger->error('place_order_consumer_failed', [
                'error' => $e->getMessage(),
            ]);
            $channel->basic_nack($message->getDeliveryTag(), requeue: false);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A long-running consumer subscribes to order.commands, hands each message to this handle method, and acks or nacks based on the result. The plumbing — connecting, declaring exchanges, subscribing — lives in a separate AmqpRunner script invoked by bin/console messaging:consume.

One use case, three entry points, zero business logic duplicated. Change a rule in Order::place and every door inherits it.

Two outbound adapters

Doctrine repository

The trick: the Doctrine adapter does not map the domain Order directly. It maps an OrderRecord persistence class and translates between record and domain in the adapter.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Application\Port\OrderRepository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use Doctrine\ORM\EntityManagerInterface;

final readonly class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private EntityManagerInterface $em,
        private OrderRecordMapper $mapper,
    ) {}

    public function save(Order $order): void
    {
        $record = $this->mapper->toRecord($order);
        $this->em->persist($record);
    }

    public function findById(OrderId $id): ?Order
    {
        $record = $this->em->find(OrderRecord::class, $id->value);
        return $record === null ? null : $this->mapper->toDomain($record);
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderRecord carries Doctrine attributes (#[Entity], #[Column], etc.). The domain Order does not. The mapper is a small pure-PHP class that copies fields and rebuilds the value objects. There is no flush() in the adapter; that happens at commit time, owned by the transactional decorator.

Payment gateway via Guzzle

<?php

declare(strict_types=1);

namespace App\Infrastructure\Payment;

use App\Application\Port\PaymentGateway;
use App\Application\Port\PaymentReceipt;
use App\Domain\Customer\CustomerId;
use App\Domain\Shared\Money;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;

final readonly class HttpPaymentGateway implements PaymentGateway
{
    public function __construct(
        private ClientInterface $http,
        private string $endpoint,
    ) {}

    public function charge(
        CustomerId $customerId,
        Money $amount,
        string $idempotencyKey,
    ): PaymentReceipt {
        try {
            $response = $this->http->request('POST', $this->endpoint, [
                'headers' => ['Idempotency-Key' => $idempotencyKey],
                'json' => [
                    'customer_id' => $customerId->value,
                    'amount_minor' => $amount->amountInMinorUnits,
                    'currency' => $amount->currency,
                ],
                'timeout' => 5,
            ]);
        } catch (RequestException $e) {
            $status = $e->getResponse()?->getStatusCode();
            if ($status === 402) {
                throw new PaymentDeclined($customerId, $amount);
            }
            throw new PaymentGatewayUnavailable(previous: $e);
        }

        $body = json_decode(
            (string) $response->getBody(),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );
        return new PaymentReceipt($body['receipt_id'], $body['status']);
    }
}
Enter fullscreen mode Exit fullscreen mode

The adapter catches Guzzle exceptions and translates them into domain exceptions (PaymentDeclined, PaymentGatewayUnavailable). The use case sees only domain types. Swap Guzzle for the Symfony HTTP client tomorrow; only this file changes.

Inside the use case — PlaceOrder orchestrates customer lookup, charge, save, and event publishing through five ports, with the transactional decorator wrapping the whole call

The composition root

One file binds every port to a concrete adapter. src/Bootstrap/Container.php:

$c->set(Clock::class, fn() => new SystemClock());
$c->set(EventBus::class, fn(C $c) => new OutboxEventBus(
    $c->get(EntityManagerInterface::class),
));
$c->set(PaymentGateway::class, fn(C $c) => new HttpPaymentGateway(
    $c->get(ClientInterface::class),
    $_ENV['PAYMENT_GATEWAY_URL'],
));
$c->set(OrderRepository::class, fn(C $c) => new DoctrineOrderRepository(
    $c->get(EntityManagerInterface::class),
    $c->get(OrderRecordMapper::class),
));
$c->set(PlaceOrder::class, fn(C $c) => new TransactionalDecorator(
    new PlaceOrder(
        $c->get(CustomerRepository::class),
        $c->get(OrderRepository::class),
        $c->get(PaymentGateway::class),
        $c->get(EventBus::class),
        $c->get(Clock::class),
    ),
    $c->get(EntityManagerInterface::class),
));
Enter fullscreen mode Exit fullscreen mode

When a new engineer joins, you point them at this file. In ten minutes they have the full picture of which adapter is plugged into which port. No AppServiceProvider doing eleven unrelated things, no services.yaml with five hundred lines of autowiring. One file, one job.

The test pyramid

Tests at three levels, each catching what its layer should catch.

tests/Unit/ is pure PHP. No Doctrine, no HTTP, no Guzzle. The use case runs with InMemoryOrderRepository, InMemoryCustomerRepository, FakePaymentGateway, FixedClock. Each fake is fifteen lines. The suite runs in seconds.

public function test_place_order_charges_and_saves(): void
{
    $clock = new FixedClock(new DateTimeImmutable('2026-05-18T10:00:00Z'));
    $customers = new InMemoryCustomerRepository([
        new Customer(new CustomerId('c-1'), 'Ada'),
    ]);
    $orders = new InMemoryOrderRepository();
    $payments = new FakePaymentGateway();
    $events = new InMemoryEventBus();

    $useCase = new PlaceOrder($customers, $orders, $payments, $events, $clock);

    $output = $useCase->execute(new PlaceOrderInput(
        customerId: 'c-1',
        items: [new LineInput('SKU-1', 2, 1500)],
        currency: 'EUR',
        idempotencyKey: 'k-1',
    ));

    self::assertSame(3000, $output->totalCents);
    self::assertCount(1, $payments->charges());
    self::assertCount(1, $orders->all());
    self::assertCount(1, $events->published());
}
Enter fullscreen mode Exit fullscreen mode

tests/Contract/ is one trait of assertions shared between adapters that implement the same port. OrderRepositoryContract defines it_saves_and_finds_by_id, it_returns_null_for_missing_id, and it_preserves_line_items. Both InMemoryOrderRepositoryTest and DoctrineOrderRepositoryTest use the trait. If both pass, the two adapters behave identically from the use case's point of view. The trait keeps the in-memory fake honest enough to test against.

tests/Integration/ runs the real Doctrine adapter against a MySQL container, the real Guzzle adapter against a WireMock-style HTTP fake, the real AMQP adapter against the RabbitMQ container. There are a dozen of these. They prove the wiring; they do not re-test domain rules.

Where this lives

The examples that pair with this post live at github.com/gabrielanhaia/php-clean-hexagonal-examples. Clone it and run docker compose up. POST to localhost:8080/orders and an order appears in MySQL, a payment goes out to the stubbed gateway, and an OrderPlaced event lands in outbox_events. The relay forwards it to RabbitMQ within a second.

Then pick one adapter and replace it. The easiest target is the persistence layer: write a RedisOrderRepository that serializes the order to JSON under order:{id}. Make it pass OrderRepositoryContract. Change the one line in Container.php that binds OrderRepository. Run the unit suite. It passes. Run the integration suite against Redis. It passes. You have changed the storage backend of an entire service by editing one binding.

If that works, the pattern is real. If it doesn't, and you find yourself reaching into the use case to fix something, you have found a coupling leak worth chasing back to its source.


If this was useful

This is the reference shape the book builds toward chapter by chapter. Decoupled PHP walks each layer from first principles to production, with the same vocabulary used here, and pushes further into outbox failure modes, sagas, schema evolution, and migrating a legacy framework-coupled codebase onto this shape one slice at a time. The public examples repo at github.com/gabrielanhaia/php-clean-hexagonal-examples is the companion code. Clone it before you read the book and the chapters click faster.

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)