DEV Community

Cover image for The Testing Pyramid for Hexagonal PHP: Unit the Core, Contract the Ports
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Testing Pyramid for Hexagonal PHP: Unit the Core, Contract the Ports


You open the CI dashboard. The test suite has been running for eleven minutes and it is on the fourth retry of a flaky checkout test. The domain code has not changed in three weeks. What changed was a Doctrine version bump, and now half the suite boots a MySQL container to assert that two plus two is four.

This is what a testing pyramid looks like when it is upside down. Most of the tests touch the database, the HTTP kernel, the queue. Almost none of them touch the rules that make the business money. You test the framework you did not write and skip the domain you did.

Hexagonal architecture gives you a cleaner answer, because it already sorts your code into layers that want different kinds of tests. The domain wants unit tests. The ports want contract tests. The whole assembled thing wants a thin end-to-end layer and nothing more.

The three layers, and what each one is afraid of

Start from the shape. A hexagonal PHP app has three concentric regions:

  • Domain: entities, value objects, the use case that orchestrates them. Pure PHP, zero framework imports.
  • Ports: interfaces the use case depends on. OrderRepository, PaymentGateway, Clock, EventBus.
  • Adapters: the concrete implementations. Doctrine, Guzzle, an AMQP consumer, an HTTP controller.

Each region fails in a different way, so each earns a different test.

The domain is afraid of getting a rule wrong: an order placed with mixed currencies, a refund larger than the charge. The ports are afraid of a lying fake: an in-memory repository that saves and finds when the real Doctrine one silently drops a column. The adapters are afraid of the wiring: a route that never reaches the use case, a transaction that never commits.

Test each fear where it lives. Do not test the domain rule through an HTTP request, and do not test the SQL through a mocked repository.

Unit-test the core, and make it fast

The use case runs against ports, not adapters. That means a unit test needs zero infrastructure. You pass in-memory fakes and a fixed clock, call execute, and assert on the result.

<?php

declare(strict_types=1);

namespace Tests\Unit\Order;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Application\Order\LineInput;
use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerId;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;

final class PlaceOrderTest extends TestCase
{
    public function test_it_charges_and_saves_an_order(): void
    {
        $clock = new FixedClock(
            new DateTimeImmutable('2026-06-01T10: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,
        );

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

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

No container, no kernel, no network. This runs in milliseconds, and it can run on every keystroke. This is where the bulk of your assertions belong: currency mismatch, empty orders, a declined card rolling nothing back, an idempotency key reused. These are the rules the business pays for, and they never need MySQL to be proven.

The fakes are cheap. FakePaymentGateway records calls and returns a receipt. FixedClock returns a set time. Each is fifteen lines. If a fake starts growing conditionals to satisfy a test, that is a signal the logic belongs in the domain, not the fake.

Contract-test the ports, so the fakes stay honest

Here is the gap. Your unit tests trust InMemoryOrderRepository. Production runs DoctrineOrderRepository. If the two behave differently, your green unit suite is lying to you.

A contract test closes that gap. Write one set of assertions against the port interface, then run it against every adapter that implements the port. PHPUnit's abstract test case makes this clean.

<?php

declare(strict_types=1);

namespace Tests\Contract;

use App\Application\Port\OrderRepository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use PHPUnit\Framework\TestCase;

abstract class OrderRepositoryContract extends TestCase
{
    abstract protected function repository(): OrderRepository;

    public function test_it_saves_and_finds_by_id(): void
    {
        $repo = $this->repository();
        $order = OrderFactory::placed('o-1');

        $repo->save($order);

        $found = $repo->findById(new OrderId('o-1'));
        self::assertNotNull($found);
        self::assertEquals(
            $order->total(), $found->total(),
        );
    }

    public function test_it_returns_null_for_a_missing_id(): void
    {
        self::assertNull(
            $this->repository()->findById(new OrderId('nope')),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Two concrete subclasses supply the adapter:

<?php

declare(strict_types=1);

namespace Tests\Contract;

use App\Application\Port\OrderRepository;
use App\Infrastructure\Persistence\InMemory\InMemoryOrderRepository;

final class InMemoryOrderRepositoryTest extends OrderRepositoryContract
{
    protected function repository(): OrderRepository
    {
        return new InMemoryOrderRepository();
    }
}
Enter fullscreen mode Exit fullscreen mode
<?php

declare(strict_types=1);

namespace Tests\Contract;

use App\Application\Port\OrderRepository;

final class DoctrineOrderRepositoryTest extends OrderRepositoryContract
{
    protected function repository(): OrderRepository
    {
        return new DoctrineOrderRepository(
            EntityManagerFactory::sqlite(),
            new OrderRecordMapper(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Same assertions, two backends. When both pass, the fake and the real adapter agree on what "save then find" means. Your unit suite is honest again. The Doctrine variant needs a database, so it costs more, but you write the assertions once and pay the boot cost on a handful of tests, not on the whole suite.

The contract layer is also where you pin adapter-specific behavior the interface promises: a unique-constraint violation surfacing as a domain DuplicateOrder exception, a findById on a soft-deleted row returning null. Anything the use case is allowed to assume goes in the contract.

Keep e2e thin, and make it prove wiring only

The top of the pyramid boots the real application: real router, real container, real database, one HTTP request in, one assertion out.

<?php

declare(strict_types=1);

namespace Tests\E2E;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class PlaceOrderHttpTest extends WebTestCase
{
    public function test_post_orders_returns_201(): void
    {
        $client = self::createClient();

        $client->request('POST', '/orders', server: [
            'CONTENT_TYPE' => 'application/json',
        ], content: json_encode([
            'customerId' => 'c-1',
            'items' => [['sku' => 'SKU-1', 'qty' => 2, 'price' => 1500]],
            'currency' => 'EUR',
            'idempotencyKey' => 'k-1',
        ]));

        self::assertResponseStatusCodeSame(201);
        $body = json_decode(
            $client->getResponse()->getContent(), true,
        );
        self::assertSame('placed', $body['status']);
    }
}
Enter fullscreen mode Exit fullscreen mode

One happy path. Maybe one auth failure and one validation error. That is the whole e2e budget for this endpoint. You are not re-testing currency rules here; the unit suite already did. You are proving the route reaches the use case, the container wires the adapters, and the transaction commits. Three or four e2e tests per entry point is plenty. When people push dozens, the suite gets slow and flaky, and the flakiness trains the team to ignore red.

What to stop testing

The pyramid is as much about deletion as addition. Stop writing these:

  • Getter and setter tests. Asserting getStatus() returns what you set proves the language works, not your code.
  • Framework behavior. You do not need a test that Doctrine persists a mapped column or that Symfony routes a POST. That is the framework's own suite.
  • Mock-heavy use-case tests. If a test mocks five ports and asserts each was called in order, it tests your wiring diagram, not your behavior. Use fakes and assert on outcomes instead.
  • The same rule at three levels. Prove the empty-order rule once, in a unit test. Do not re-prove it through the repository and again through HTTP. Duplicated coverage costs runtime and gives false confidence.

The test you delete is the test that never flakes at 2 a.m.

The shape that results

Count the tests and the pyramid appears on its own. Hundreds of unit tests on the domain, running in a second or two. A dozen or so contract tests per port, running against a real database in seconds. A short e2e layer that boots the app and checks the seams. Fast where it matters, slow only where it must be.

The layer boundaries did the sorting. Because the domain has no framework imports, it is trivially unit-testable. Because ports are interfaces, they are contract-testable. Because adapters are thin, they need almost no tests of their own. Good architecture is what makes a healthy pyramid the path of least resistance.

Keeping tests fast is a property of keeping the domain free of infrastructure, which is the whole argument of hexagonal PHP: the framework is an adapter at the edge, not a dependency threaded through your business rules. When your core has no imports to mock, the pyramid stops fighting you. That decoupling, and the test strategy that falls out of it, is what Decoupled PHP works through, layer by layer.

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)