- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- 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 five-year-old Symfony service to add one endpoint. The entity has nineteen annotations. Two of them point at lifecycle callbacks in a trait. The trait points at an event subscriber. The event subscriber calls a service that loads a related entity that has its own subscriber. Somewhere in that chain, a JOIN you didn't ask for got added to every query against this table. You only wanted to add a column.
The codebase is fine. The team is fine. Doctrine is fine. The mismatch is elsewhere. The service does CRUD on three tables, and the ORM is solving problems it doesn't have.
For a real slice of the work you ship in PHP (internal tools, reporting endpoints, write-once-read-many tables, integration glue), a thin DBAL plus a hand-written row mapper is the right tool. This post shows what that looks like in PHP 8.3 with Doctrine DBAL 4, where it scales, and the exact line where it stops being a good idea.
What "thin DBAL plus mapper" actually means
Two pieces, both small:
-
DBAL: Doctrine's database abstraction layer, the package below the ORM. Connection pooling, parameterized queries, a small
QueryBuilder, and platform-aware type handling. Same library most Symfony shops already have installed; just the lower half of it. - A mapper: one class per aggregate that knows how to turn a row (or a set of rows) into a domain object, and a domain object back into the columns the table expects. No proxies, no metadata, no first-level cache. Just a function in each direction.
The domain object is a plain PHP class. No extends Model, no #[ORM\Entity], no lifecycle hooks. The mapper sits in the adapter layer next to the SQL.
A worked example
The domain is an Order with line items and a status. The table is orders, with a related order_items. Two tables, one aggregate.
The domain object
<?php
declare(strict_types=1);
namespace App\Checkout\Domain;
use DateTimeImmutable;
final class Order
{
/** @param list<OrderItem> $items */
public function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly OrderStatus $status,
public readonly array $items,
public readonly int $totalCents,
public readonly DateTimeImmutable $createdAt,
) {}
/** @param list<OrderItem> $items */
public static function place(
string $id,
string $customerId,
array $items,
DateTimeImmutable $now,
): self {
$total = array_sum(array_map(
static fn (OrderItem $i): int => $i->priceCents * $i->quantity,
$items,
));
return new self(
$id, $customerId, OrderStatus::Pending,
$items, $total, $now,
);
}
}
final class OrderItem
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly int $priceCents,
) {}
}
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
}
No annotations. No base class. No getId() boilerplate. PHP 8.1+ readonly properties cover it. This file does not know that a database exists.
The port
The repository interface lives next to the domain, expressed in domain words:
<?php
declare(strict_types=1);
namespace App\Checkout\Domain;
interface OrderRepository
{
public function save(Order $order): void;
public function findById(string $id): ?Order;
}
One contract. Two methods. No findBy, no Criteria, no EntityManager. Whatever the use case actually calls, that goes on the port. Nothing else.
The mapper
This is the file the ORM was hiding. About fifty lines.
<?php
declare(strict_types=1);
namespace App\Checkout\Adapter\Persistence;
use App\Checkout\Domain\Order;
use App\Checkout\Domain\OrderItem;
use App\Checkout\Domain\OrderStatus;
use DateTimeImmutable;
final class OrderMapper
{
/**
* @param array<string, mixed> $orderRow
* @param list<array<string, mixed>> $itemRows
*/
public function toDomain(array $orderRow, array $itemRows): Order
{
$items = array_map(
static fn (array $r): OrderItem => new OrderItem(
sku: (string) $r['sku'],
quantity: (int) $r['quantity'],
priceCents: (int) $r['price_cents'],
),
$itemRows,
);
return new Order(
id: (string) $orderRow['id'],
customerId: (string) $orderRow['customer_id'],
status: OrderStatus::from((string) $orderRow['status']),
items: $items,
totalCents: (int) $orderRow['total_cents'],
createdAt: new DateTimeImmutable($orderRow['created_at']),
);
}
/** @return array<string, scalar> */
public function toOrderRow(Order $o): array
{
return [
'id' => $o->id,
'customer_id' => $o->customerId,
'status' => $o->status->value,
'total_cents' => $o->totalCents,
'created_at' => $o->createdAt->format('Y-m-d H:i:s'),
];
}
/** @return list<array<string, scalar>> */
public function toItemRows(Order $o): array
{
return array_map(
static fn (OrderItem $i): array => [
'order_id' => $o->id,
'sku' => $i->sku,
'quantity' => $i->quantity,
'price_cents' => $i->priceCents,
],
$o->items,
);
}
}
Everything the ORM was inferring from metadata is now an explicit line of PHP. Adding a column is two edits: one in the domain class, one in toDomain / toOrderRow. Renaming total_cents to total_minor_units is grep-and-replace inside one file.
The adapter
The mapper does no I/O. The adapter does the I/O and calls the mapper.
<?php
declare(strict_types=1);
namespace App\Checkout\Adapter\Persistence;
use App\Checkout\Domain\Order;
use App\Checkout\Domain\OrderRepository;
use Doctrine\DBAL\Connection;
final class DbalOrderRepository implements OrderRepository
{
public function __construct(
private readonly Connection $db,
private readonly OrderMapper $mapper,
) {}
public function save(Order $order): void
{
$this->db->transactional(function (Connection $tx) use ($order): void {
$tx->executeStatement(
'INSERT INTO orders
(id, customer_id, status, total_cents, created_at)
VALUES (:id, :customer_id, :status, :total_cents, :created_at)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
total_cents = EXCLUDED.total_cents',
$this->mapper->toOrderRow($order),
);
$tx->executeStatement(
'DELETE FROM order_items WHERE order_id = :id',
['id' => $order->id],
);
foreach ($this->mapper->toItemRows($order) as $row) {
$tx->insert('order_items', $row);
}
});
}
public function findById(string $id): ?Order
{
$orderRow = $this->db->fetchAssociative(
'SELECT id, customer_id, status, total_cents, created_at
FROM orders WHERE id = :id',
['id' => $id],
);
if ($orderRow === false) {
return null;
}
$itemRows = $this->db->fetchAllAssociative(
'SELECT sku, quantity, price_cents
FROM order_items WHERE order_id = :id',
['id' => $id],
);
return $this->mapper->toDomain($orderRow, $itemRows);
}
}
Two SQL statements, both visible. No proxy class loaded by Composer. No EntityManager::flush() that decides for you which inserts to issue and in what order. If a junior on the team opens this file, they can read what hits the database.
The test that pins the contract
This is where the approach earns its keep. The repository has a port (OrderRepository); both the real DbalOrderRepository and any in-memory fake have to satisfy the same contract. Write the contract test once, run it against both.
<?php
declare(strict_types=1);
namespace Tests\Checkout\Adapter;
use App\Checkout\Domain\Order;
use App\Checkout\Domain\OrderItem;
use App\Checkout\Domain\OrderRepository;
use App\Checkout\Domain\OrderStatus;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
abstract class OrderRepositoryContractTest extends TestCase
{
abstract protected function repo(): OrderRepository;
#[Test]
public function roundtrips_an_order_with_items(): void
{
$order = Order::place(
id: 'ord-1',
customerId: 'cust-1',
items: [
new OrderItem('SKU-A', 2, 1500),
new OrderItem('SKU-B', 1, 900),
],
now: new DateTimeImmutable('2026-05-18 10:00:00'),
);
$this->repo()->save($order);
$found = $this->repo()->findById('ord-1');
self::assertNotNull($found);
self::assertSame('cust-1', $found->customerId);
self::assertSame(OrderStatus::Pending, $found->status);
self::assertSame(3900, $found->totalCents);
self::assertCount(2, $found->items);
}
#[Test]
public function returns_null_for_unknown_id(): void
{
self::assertNull($this->repo()->findById('missing'));
}
}
The unit suite runs this against an in-memory implementation in milliseconds. The integration suite extends the same class and points it at a real PostgreSQL container. If the in-memory fake drifts from the real adapter, the contract test fails. That's the whole point of having a port.
When this scales
The thin-mapper approach is happiest when:
- The aggregate has a stable shape. Columns don't churn weekly. Five-to-fifteen scalar fields, maybe one child collection.
- Reads are dominated by query patterns you can name.
findById,findByCustomer,findRecentInWindow. Not "load the object graph and let the ORM figure out what JOINs are needed." - Writes happen in deliberate use cases.
place,confirm,cancel. Not "mutate fields and callflush()." - You want to see the SQL when reading a PR. The team treats SQL as code, not as something the framework generates.
- The data layer outlives the framework. Today it's Symfony; in three years it might be a long-running queue worker or a CLI tool that processes the same tables. The mapper does not care.
All of these are happier without an ORM: internal admin endpoints, reporting pipelines, write-heavy event-style tables, integration adapters that read one shape and write another. So is most of the migration work in a strangler refactor: the new code wants explicit SQL while the legacy ORM-coupled code is still in flight.
Where Doctrine ORM still wins
This isn't a "ORMs are bad" post. Reach for Doctrine ORM (or Eloquent) when:
-
Aggregates have rich, mutable state. A
Subscriptionwithpause,resume,upgrade,expire,reactivate, where the framework's change tracking andflush()save you from writing five different UPDATE statements by hand. -
The object graph is deep and lazy-loading actually pays. Five layers of associations, each used by a different feature, and you'd rather declare relationships than maintain bespoke
fetchAllhelpers per use case. -
You want migrations generated from metadata. Doctrine's
schema:updateand Symfony Maker work because the entity is the source of truth. Thin-mapper code makes you own the schema separately, usually via plain SQL migrations. That's a feature for some teams and a tax for others. -
The team already speaks Doctrine fluently. A senior crew that thinks in terms of
EntityManager,UnitOfWork, and lifecycle events will be slower with a thin mapper. Tools you understand beat tools you don't.
Most of the pain people blame on Doctrine ORM is the pain of fighting an ORM in a place where the data shape is too simple to need one.
The cost in code, made honest
Switching to the thin-mapper approach is not free. You pay in:
-
One mapper class per aggregate. Roughly 30–60 lines per repository. Boring code, but more of it than
#[ORM\Column]would have been. - Hand-written migrations. No schema generated from metadata. You write the SQL, you own the SQL.
- Manual relationship loading. No lazy proxies. If the use case needs items, the adapter fetches items in the same method. Often this is two queries instead of one JOIN; pick whichever the EXPLAIN justifies.
-
No identity map. Two
findByIdcalls in the same request return two different PHP objects. If your code relied on===between entities, that breaks. If you compared by->id, you were fine the whole time. - No event subscribers attached to persistence. Domain events live in the use case, dispatched explicitly. That's usually what you wanted anyway. Implicit lifecycle events are exactly the kind of magic that makes a five-year-old codebase hard to reason about.
The trade is more lines for fewer surprises. A new engineer reading DbalOrderRepository sees every query. There is no "but where does the second query come from?". There isn't a second query unless you wrote it.
How to introduce this without a rewrite
If you have a Doctrine-heavy codebase and want to try this on the next feature, the path is mechanical:
-
Define the port in the domain. Write
interface ThingRepositorywith the methods the use case actually calls. -
Keep the Doctrine entity if it exists. Write a small Doctrine adapter that implements the new port and delegates to
EntityManagerinternally. Use cases now depend on the port, not onEntityManagerdirectly. - For the new aggregate, skip the entity. Write the domain class, the mapper, and a DBAL adapter that satisfies the port. Wire both into the container side by side.
- Run the contract test against both adapters. Migrations stay where they were.
You end up with two adapters living in the same codebase, each appropriate for its aggregate. That is fine. Architecture is a set of decisions per bounded slice, not a uniform. The point of the port layer is exactly to let you make different decisions on different sides of the same application.
If this was useful
Decoupled PHP walks the full Ports and Adapters layout for PHP 8.3+: where the domain lives, how use cases compose, when to keep an ORM as one adapter and a thin DBAL repo as another, and how to migrate a Laravel or Symfony service to that shape without a freeze week. If you've ever opened a five-year-old controller and felt the framework had eaten the business logic, this is the book that puts the logic back at the centre.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now. Portuguese and Spanish coming soon.



Top comments (0)