DEV Community

Cover image for Doctrine 3 Behind a Port: The Pattern That Lets You Swap ORMs
Gabriel Anhaia
Gabriel Anhaia

Posted on

Doctrine 3 Behind a Port: The Pattern That Lets You Swap ORMs


You inherit a PHP service. It's four years old, two framework majors deep, and the team wants to move from Doctrine to something else. Maybe Eloquent because the rest of the company speaks Laravel. The other candidates on the table are DBAL (the ORM proxies are noticeable in profiles) and a Postgres-native client (the team is tired of fighting hydration on the read paths).

You open the codebase and start counting. EntityManagerInterface in 38 files. #[ORM\Entity] on every domain class. findBy, flush, persist, getReference scattered through controllers, console commands, queue workers, even a couple of Twig helpers. There is no "swap the ORM" task here. There is a six-month rewrite with a freeze week and a fork in production.

You don't want to be that team in two years.

The pattern below is the one you reach for. Define the repository as an interface that speaks domain language, then implement it once with Doctrine 3. Keep a second implementation in memory so your tests run without a database. The day a different storage choice becomes the right one, you write a third implementation and change one line of wiring. The use cases never know.

What the port looks like (and what it doesn't)

The port is an interface. It lives next to the domain. It names domain types, never database types.

<?php
declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;

interface OrderRepository
{
    public function find(OrderId $id): ?Order;

    public function save(Order $order): void;

    /** @return list<Order> */
    public function findActiveByCustomer(CustomerId $customerId): array;
}
Enter fullscreen mode Exit fullscreen mode

Read what's there and what isn't. The signatures take OrderId and CustomerId, not strings. They return Order or list<Order>, not arrays of rows. There is no findBy, no QueryBuilder, no DQL fragment, no flush. Nothing in this interface tells you whether the store is Postgres, MySQL, SQLite, or a JSON file on disk.

That is the whole trick.

A repository that exposes findBy(['status' => 'placed']) is a Doctrine repository pretending to be a domain port. Move it to Eloquent and the array shape changes. Move it to DBAL and you're handing the use case raw rows. The interface leaks the implementation. The "port" is a Doctrine repository with a different namespace.

A repository that exposes three verbs in domain language gives every implementation the same job: take a domain object and store it, take a domain identifier and return a domain object. How you do it is your problem; the caller does not know.

Domain port versus Doctrine-shaped port: the first hides the store, the second leaks it.

The domain Order is equally careful:

<?php
declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;
use App\Domain\Shared\Money;

final class Order
{
    private function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
        private array $lineItems,
        private OrderStatus $status,
        public readonly \DateTimeImmutable $placedAt,
    ) {}

    public static function place(
        OrderId $id,
        CustomerId $customerId,
        array $lineItems,
        \DateTimeImmutable $placedAt,
    ): self {
        if ($lineItems === []) {
            throw new \DomainException('Order needs at least one line item.');
        }
        return new self($id, $customerId, $lineItems, OrderStatus::Placed, $placedAt);
    }

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

    public function total(): Money
    {
        $total = Money::zero('EUR');
        foreach ($this->lineItems as $item) {
            $total = $total->add($item->subtotal());
        }
        return $total;
    }

    public function status(): OrderStatus
    {
        return $this->status;
    }
}
Enter fullscreen mode Exit fullscreen mode

No Doctrine attributes. No extends Model. No annotations. Two named constructors: place for new orders (with the invariants), and restore for rebuilds from persistence (without the event side effects). The Doctrine adapter calls restore while tests that exercise behavior call place. The class never imports a single line from Doctrine\.

The Doctrine 3 adapter

Doctrine 3 ships with attribute mapping as the default. XML and YAML are still around for legacy reasons, but attributes are what new code uses. You map a record class (not the domain Order) and translate between the two with a mapper.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class OrderRecord
{
    #[ORM\Id]
    #[ORM\Column(type: Types::STRING, length: 36)]
    public string $id;

    #[ORM\Column(name: 'customer_id', type: Types::STRING, length: 36)]
    public string $customerId;

    #[ORM\Column(name: 'total_minor_units', type: Types::INTEGER)]
    public int $totalMinorUnits;

    #[ORM\Column(type: Types::STRING, length: 3)]
    public string $currency;

    #[ORM\Column(type: Types::STRING, length: 16)]
    public string $status;

    #[ORM\Column(name: 'placed_at', type: Types::DATETIME_IMMUTABLE)]
    public \DateTimeImmutable $placedAt;
}
Enter fullscreen mode Exit fullscreen mode

Public properties on purpose. The record has no behavior; it is a typed row, and the only file that ever touches it sits next to it.

The mapper does the translation in both directions:

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Domain\Customer\CustomerId;
use App\Domain\Order\{Order, OrderId, OrderStatus};

final class OrderMapper
{
    public function __construct(
        private readonly LineItemMapper $lineItems,
    ) {}

    public function toDomain(OrderRecord $record): Order
    {
        return Order::restore(
            id:         new OrderId($record->id),
            customerId: new CustomerId($record->customerId),
            lineItems:  $this->lineItems->loadFor($record),
            status:     OrderStatus::from($record->status),
            placedAt:   $record->placedAt,
        );
    }

    public function toRecord(
        Order $order,
        ?OrderRecord $existing = null,
    ): OrderRecord {
        $record = $existing ?? new OrderRecord();
        $total  = $order->total();

        $record->id              = $order->id->value;
        $record->customerId      = $order->customerId->value;
        $record->totalMinorUnits = $total->amountInMinorUnits;
        $record->currency        = $total->currency;
        $record->status          = $order->status()->value;
        $record->placedAt        = $order->placedAt;

        return $record;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the repository. This is the one piece that imports Doctrine\ORM\EntityManagerInterface and lives up to the port:

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Domain\Customer\CustomerId;
use App\Domain\Order\{Order, OrderId, OrderRepository};
use Doctrine\ORM\EntityManagerInterface;

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

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

    public function save(Order $order): void
    {
        $existing = $this->em->find(OrderRecord::class, $order->id->value);
        $record   = $this->mapper->toRecord($order, $existing);

        if ($existing === null) {
            $this->em->persist($record);
        }
    }

    /** @return list<Order> */
    public function findActiveByCustomer(CustomerId $customerId): array
    {
        $records = $this->em
            ->getRepository(OrderRecord::class)
            ->findBy([
                'customerId' => $customerId->value,
                'status'     => ['placed', 'paid'],
            ]);

        return array_map(
            fn (OrderRecord $r) => $this->mapper->toDomain($r),
            $records,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The injected type is EntityManagerInterface, not the concrete EntityManager. The interface is part of doctrine/orm and it's the right seam for a unit test that needs a fake.

save() does not call flush(). The repository is not the place that decides when to commit. A use case might save two aggregates and publish three events; if each save() flushed, you'd get three separate transactions and a partial-failure window between them. The right boundary is the use case, wrapped by a transactional decorator. The repository's job is to persist; deciding when to write is somebody else's call.

findBy lives inside the adapter. The port never sees it. The port returns list<Order>; how the implementation gets there is its own business. A future DBAL implementation writes raw SQL; an Eloquent one calls Order::whereIn(...)->get(). Both satisfy the same interface.

The in-memory adapter for tests

Now the part that pays you back tomorrow. The same OrderRepository interface gets a second implementation that stores objects in a PHP array.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\InMemory;

use App\Domain\Customer\CustomerId;
use App\Domain\Order\{Order, OrderId, OrderRepository, OrderStatus};

final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, Order> */
    private array $byId = [];

    public function find(OrderId $id): ?Order
    {
        return $this->byId[$id->value] ?? null;
    }

    public function save(Order $order): void
    {
        $this->byId[$order->id->value] = $order;
    }

    /** @return list<Order> */
    public function findActiveByCustomer(CustomerId $customerId): array
    {
        $active = [OrderStatus::Placed, OrderStatus::Paid];

        return array_values(array_filter(
            $this->byId,
            fn (Order $o) =>
                $o->customerId->value === $customerId->value
                && in_array($o->status(), $active, true),
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

A couple dozen lines of code. No fixtures, no migrations, no dbunit, no SQLite. Inject this into the use case and every test runs in-process, with no I/O.

The use case itself is identical regardless of which adapter is wired:

<?php
declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\{Order, OrderId, OrderRepository};
use App\Domain\Customer\CustomerId;

final class PlaceOrder
{
    public function __construct(
        private readonly OrderRepository $orders,
    ) {}

    public function __invoke(PlaceOrderInput $input): OrderId
    {
        $order = Order::place(
            id:         OrderId::generate(),
            customerId: new CustomerId($input->customerId),
            lineItems:  $input->lineItems,
            placedAt:   new \DateTimeImmutable(),
        );

        $this->orders->save($order);

        return $order->id;
    }
}
Enter fullscreen mode Exit fullscreen mode

PlaceOrder does not know whether $this->orders is a Doctrine implementation, an in-memory implementation, or something else. It calls save. The next test for this class:

<?php
declare(strict_types=1);

use App\Application\Order\{PlaceOrder, PlaceOrderInput};
use App\Domain\Order\LineItem;
use App\Domain\Shared\Money;
use App\Infrastructure\Persistence\InMemory\InMemoryOrderRepository;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class PlaceOrderTest extends TestCase
{
    #[Test]
    public function it_persists_a_placed_order(): void
    {
        $repo = new InMemoryOrderRepository();
        $useCase = new PlaceOrder($repo);

        $id = $useCase(new PlaceOrderInput(
            customerId: 'cust-1',
            lineItems:  [new LineItem('sku-A', 2, Money::ofMinorUnits(1500, 'EUR'))],
        ));

        $saved = $repo->find($id);
        $this->assertNotNull($saved);
        $this->assertSame(3000, $saved->total()->amountInMinorUnits);
    }
}
Enter fullscreen mode Exit fullscreen mode

No Doctrine. No EntityManager. No database container in CI. The test boots in single-digit milliseconds and runs the same use case the production wiring runs.

This is the actual payoff of putting Doctrine behind a port. The swap-the-ORM story is the headline, and you collect the rent on it every test run.

Contract tests: the bit most teams skip

Two implementations of the same interface only stay equivalent if you test them against the same assertions. Otherwise the in-memory adapter drifts: it returns rows in insertion order while Postgres returns them by primary key, or it lets you save the same OrderId twice while a UNIQUE constraint would reject the second one. The unit tests stay green, then integration fails six weeks later.

A contract test fixes this. Write one abstract test class against the port, run it against every implementation.

<?php
declare(strict_types=1);

namespace App\Tests\Order;

use App\Domain\Order\{Order, OrderId, OrderRepository};
use App\Domain\Customer\CustomerId;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

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

    #[Test]
    public function it_returns_null_for_unknown_id(): void
    {
        $repo = $this->repository();
        $this->assertNull($repo->find(new OrderId('unknown')));
    }

    #[Test]
    public function it_round_trips_a_placed_order(): void
    {
        $repo  = $this->repository();
        $order = $this->anOrder();

        $repo->save($order);
        $fetched = $repo->find($order->id);

        $this->assertNotNull($fetched);
        $this->assertEquals($order->id, $fetched->id);
        $this->assertSame(
            $order->total()->amountInMinorUnits,
            $fetched->total()->amountInMinorUnits,
        );
    }

    private function anOrder(): Order
    {
        // helper that returns a valid Order instance
    }
}
Enter fullscreen mode Exit fullscreen mode

Then two concrete subclasses:

final class InMemoryOrderRepositoryTest extends OrderRepositoryContract
{
    protected function repository(): OrderRepository
    {
        return new InMemoryOrderRepository();
    }
}

final class DoctrineOrderRepositoryTest extends OrderRepositoryContract
{
    protected function repository(): OrderRepository
    {
        return new DoctrineOrderRepository(
            self::bootDoctrineWithSqliteInMemory(),
            new OrderMapper(new LineItemMapper()),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Same assertions, two stores. If InMemoryOrderRepository lets you save the same ID twice, the corresponding contract test fails on it just as it fails on Doctrine. The two adapters stay in lockstep, and the day a third one shows up it inherits the same test class.

One port, three adapters, one shared contract test suite — what changes and what doesn't.

What changes on the day you swap ORMs

Let's run the migration story. The team decides Doctrine is the wrong fit and Eloquent is the new home.

Files that change:

  • A new App\Infrastructure\Persistence\Eloquent\EloquentOrderRepository implementing OrderRepository.
  • A new EloquentOrderModel (whatever shape Eloquent wants).
  • A new mapper between EloquentOrderModel and the domain Order.
  • One line in the container: bind OrderRepository to the Eloquent class instead of the Doctrine class.
  • The contract test gets a third subclass.

Files that do not change:

  • Order, OrderId, OrderStatus, Money, LineItem: the whole domain.
  • PlaceOrder, CancelOrder, every use case.
  • The HTTP controllers, console commands, queue workers: all the inbound adapters.
  • The port OrderRepository.
  • Every unit test against the use cases.

That's the trade you signed up for. You pay a one-time mapping tax on every aggregate. In return, the day a different store becomes the right one, the change is bounded. The use cases never see Doctrine, so they never see Doctrine's absence either.

Same story for an in-memory adapter you ship to production for a feature flag, a DBAL implementation for a read-heavy projection, or a Redis-backed cache wrapper that delegates to the real repository on miss. Each is a class that implements the port. None of them touch the domain.

When this is overkill

Honest section. The pattern earns its keep when the application lives long enough to need it, and not before.

Skip the port and the mapper if:

  • You're shipping a CRUD admin tool with twenty tables and no business logic worth a domain class.
  • The service has been described to you as "thin wrapper around the database."
  • You're prototyping and the whole thing might be deleted in a month.

Build the port and the mapper if:

  • There is at least one aggregate with non-trivial invariants.
  • You expect the system to outlive its current framework version.
  • Fast use-case tests in CI without a database are something the team actually wants.
  • The product owner has ever said "we might move to X" about any external piece of the stack.

The first list is most CRUD apps. The second list is most services that pay engineers salaries to maintain them. Both shapes ship. Pick honestly.

What you keep

The day Doctrine 4 ships and the migration is painful, you write the v4 adapter alongside the v3 one, run both against the contract test, and switch the container binding when the contract test stays green. You will not have a freeze week. You will not fork production. You will have a pull request.


If this was useful

The port-and-mapper pattern is the spine of how Decoupled PHP treats persistence. The book walks the full reference application (Doctrine, Guzzle for a payment gateway, AMQP for an event bus), every one of them sitting behind a port the domain owns. If you've ever inherited a Laravel or Symfony service where the framework reached all the way into the business logic, the book is the playbook for getting back out.

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)