You write $entityManager->flush(), the data appears in the database, and everything feels clean.
Until one day it doesn't.
Suddenly a simple endpoint runs 300 queries. Memory usage climbs during an import. A background worker keeps stale entities around. A migration looks safe but blocks a large table. Doctrine is still doing what it was designed to do, but your mental model is too small.
Doctrine ORM is powerful because it gives you an object model over relational data. It is dangerous when you forget that relational data is still relational data.
EntityManager Is Not Just A Repository Factory
The EntityManager coordinates object persistence. It loads entities, tracks changes, manages identity, and commits changes through flush().
Think of it like a clipboard carried by a warehouse manager. Every entity you load or create goes onto that clipboard. When you call flush(), the manager compares notes and writes the needed changes to the database.
public function rename(string $id, string $newName): void
{
$customer = $this->entityManager
->getRepository(Customer::class)
->find($id);
$customer->rename($newName);
$this->entityManager->flush();
}
The missing line is the interesting part. There is no explicit save($customer). Doctrine already tracks the loaded object.
That feels magical, but it is not free.
Unit Of Work Is The Hidden Engine
Doctrine's Unit of Work tracks entity state: new, managed, removed, detached. When you call flush(), it calculates what changed and generates SQL.
That is elegant for normal requests. It can become expensive in large loops.
foreach ($rows as $index => $row) {
$product = new Product($row['sku'], $row['name']);
$entityManager->persist($product);
if ($index % 100 === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();
Batching keeps memory under control because Doctrine does not keep every imported object managed forever.
Without this, your import can turn into a backpack that gets heavier with every step.
Lazy Loading Is Convenient Until It Becomes Invisible I/O
Lazy loading lets Doctrine load related objects only when you access them. That is useful. It also creates the classic N+1 query problem.
foreach ($orders as $order) {
echo $order->getCustomer()->getEmail();
}
This code looks innocent. But if customer is lazily loaded, Doctrine may run one query for orders and then one extra query per order.
Use joins when you know you need the relationship.
public function findRecentWithCustomers(): array
{
return $this->createQueryBuilder('o')
->addSelect('c')
->join('o.customer', 'c')
->orderBy('o.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()
->getResult();
}
Now the query communicates intent: load orders and customers together.
Hydration Has A Cost
Doctrine does not just return rows. It hydrates rows into objects, wires relationships, tracks identity, and attaches entities to the Unit of Work.
That is useful when you need rich domain objects. It is wasteful when you only need a report.
public function revenueByDay(): array
{
return $this->createQueryBuilder('o')
->select('DATE(o.createdAt) AS day, SUM(o.totalInCents) AS revenue')
->groupBy('day')
->getQuery()
->getArrayResult();
}
For read-heavy reporting, arrays or scalar results can be the right tool.
Not every database query needs to become a living object graph.
DQL, Query Builder, And DBAL — When To Reach For Each
Doctrine gives you three layers below the repository, and they are not interchangeable.
Repository methods (findBy, findOneBy) are great for trivial lookups: one entity by id, a small list by simple criteria. They get awkward as soon as you need joins, conditional WHERE, or precise SELECT shape.
DQL is Doctrine's object query language. It looks like SQL but works on entities and properties:
$qb = $em->createQuery('
SELECT o, c
FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status
ORDER BY o.createdAt DESC
')->setParameter('status', 'paid');
DQL is portable across databases and stays in the entity world. Reach for it when the query maps cleanly to your domain.
Query Builder is the same thing built fluently in PHP:
$qb = $em->createQueryBuilder()
->select('o', 'c')
->from(Order::class, 'o')
->join('o.customer', 'c')
->where('o.status = :status')
->setParameter('status', 'paid')
->orderBy('o.createdAt', 'DESC');
Use it when the query is built up conditionally — search filters, dynamic facets, optional sort columns.
DBAL is the layer below the ORM — direct SQL with parameter binding, no entity hydration:
$rows = $em->getConnection()
->executeQuery(
'SELECT date_trunc(\'day\', created_at) AS day, SUM(total_cents) AS revenue
FROM orders WHERE status = ? GROUP BY day ORDER BY day',
['paid']
)
->fetchAllAssociative();
DBAL shines for reports, bulk updates, and database-specific features (PostgreSQL LATERAL, MySQL hints, window functions, JSONB operators). You skip the ORM tax entirely. The trade-off: you lose entity behavior, identity, and portability. Don't use DBAL inside a request that's already managing entities — it bypasses the Unit of Work and can leave you with stale objects.
A useful rule: start in the repository, drop to DQL/QueryBuilder when the shape demands it, drop to DBAL when the ORM is fighting you. Each step down is more power and more responsibility.
Migrations Are Not A Rubber Stamp
Doctrine Migrations are excellent, but generated migrations are not automatically production-safe.
A migration is like surgery on a living system. The generated file may be technically correct, but it does not know your traffic, table size, locking behavior, deployment sequence, or rollback strategy.
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE customer ADD preferred_locale VARCHAR(10) DEFAULT NULL');
}
This kind of nullable additive change is usually easier to deploy than renaming a column, changing a type, or adding a non-null column with a default on a very large table.
For risky changes, use expand-and-contract: add new structure, deploy compatible code, backfill data, switch reads/writes, then remove old structure later.
Read Models Beat Entities For Reports
Once your app needs dashboards, exports, analytics, or any "show me a wide table of summarized data" feature, entities become the wrong shape.
A read model is a class purpose-built for one query result. It has no Doctrine annotations, no Unit of Work registration, no relationships — just typed properties that match the columns you SELECT.
final readonly class RevenueRow
{
public function __construct(
public string $day,
public int $revenueCents,
public int $orderCount,
) {}
}
public function byDay(\DateTimeImmutable $since): array
{
$rows = $this->connection->executeQuery(
'SELECT date_trunc(\'day\', created_at) AS day,
SUM(total_cents)::int AS revenue,
COUNT(*)::int AS order_count
FROM orders
WHERE status = \'paid\' AND created_at >= ?
GROUP BY day ORDER BY day',
[$since->format('Y-m-d H:i:s')]
)->fetchAllAssociative();
return array_map(
fn (array $r) => new RevenueRow($r['day'], $r['revenue'], $r['order_count']),
$rows
);
}
The repository does the SQL, the read model gives you typed access, and the Unit of Work never gets involved. Reports stay fast even when the entity model grows complicated.
The pattern scales: API list endpoints, admin search results, CSV exports, and dashboards all become much easier to optimize when you stop forcing them through entities.
Common Doctrine Problems
- Calling
flush()too often — Flushing inside every loop iteration can destroy performance. - Calling
flush()too late — Keeping too many managed entities can grow memory usage. - Trusting lazy loading in API serialization — Serializers can accidentally walk relationships and trigger query storms.
- Using entities for every read model — Reports, dashboards, and exports often deserve custom queries.
- Ignoring transaction boundaries —
flush()writes changes, but business workflows still need transaction design.
Doctrine is not the problem. An unclear mental model is.
Pro Tips
- Use the profiler during development — Query count matters as much as code beauty.
- Design repositories around use cases —
findRecentWithCustomers()is clearer than generic query soup in controllers. - Batch long-running commands — Use
flush()andclear()intentionally. - Keep entities focused — Entities should model domain behavior, not become API response bags.
- Reach for DBAL or SQL when needed — Raw SQL is not failure. Sometimes it is respect for the database.
Final Tips
I used to treat ORM-generated SQL like background noise. Then production taught me that background noise can be an alarm bell if you finally listen to it.
Doctrine is fantastic when you use it with eyes open: understand the Unit of Work, control lazy loading, measure hydration cost, and review migrations like production code.
Use the ORM, but do not outsource your database thinking. Go build it carefully 👊




