The Doctrine Performance Patterns That Actually Matter

A practical guide to Doctrine performance beyond N+1, covering hydration strategy, entity manager cost, joins, caching, and profiling.

A glowing Doctrine logo at the center of a dark 3D scene, with a tangled mass of purple data cables on the left representing messy write paths and clean blue streams fanning out to database server icons on the right representing well-shaped read paths

Most Doctrine performance conversations start and end with N+1. The junior engineer finds the profiler, sees a hundred queries where there should be one, adds an EAGER fetch mode or a JOIN FETCH, and the page loads faster. The senior engineer nods. The ticket closes.

That is fine as far as it goes, but it is not the conversation that matters for a system approaching real scale. Once the obvious N+1 queries are gone, performance problems stop coming from query count and start coming from the shape of your object graph, the cost of the entity manager, and the mismatch between how you write and how you read. The remaining wins are smaller per fix but add up to the difference between a system that holds under load and one that does not.

This essay is about those remaining wins. The five patterns I reach for after the N+1 audit is done, in rough order of impact.

1. Stop hydrating entities on read paths you do not need to write

The first thing I do on any Doctrine performance engagement is look at the hottest read paths and ask: does this path ever mutate the entities it loads? For most dashboards, list pages, search results, admin grids, API GET endpoints, the answer is no. The request reads data, formats it, returns it. There is no write.

Doctrine does not know this. By default, it hydrates every row into a managed entity, registers it in the identity map, wires up proxies for unloaded relations, and schedules it for change tracking. On a list view that shows 50 rows with a handful of relations each, that is a lot of machinery running for no benefit.

The fix is to hydrate differently on read paths. Three options, in increasing order of effort and return.

Array hydration for simple lists. If the data is a flat projection (column names as keys, scalar values), getArrayResult() or Query::HYDRATE_ARRAY skips the entity construction entirely. On a list of 500 rows this is typically 5-10x faster than object hydration and uses a fraction of the memory. The catch: you lose type safety and any behavior on the entity. That is fine for a response body you are about to JSON-encode.

PHP
$rows = $this->em->createQuery(
    'SELECT p.id, p.title, p.publishedAt
       FROM App\Entity\Post p
      WHERE p.status = :status
      ORDER BY p.publishedAt DESC'
)
    ->setParameter('status', 'published')
    ->setMaxResults(50)
    ->getArrayResult();

DTO hydration for structured reads. When you need a shaped object, not a flat array, Doctrine supports scalar results hydrated directly into DTOs via SELECT NEW:

PHP
$posts = $this->em->createQuery(
    'SELECT NEW App\ReadModel\PostListItem(p.id, p.title, p.publishedAt, a.name)
       FROM App\Entity\Post p
       JOIN p.author a
      WHERE p.status = :status
      ORDER BY p.publishedAt DESC'
)->setParameter('status', 'published')->getResult();

The DTO constructor controls the contract. No lazy loading, no hydration of unused columns, no identity map entries. For list-page read paths, this is almost always the right pattern. It also encodes the read contract in PHP types, which is worth something on its own.

Dedicated read models for the hot pages. For the three or four endpoints that carry the bulk of your traffic, go one step further and build a denormalized read model, updated via domain events or a refresh job, queried as a flat table with no joins. This is the point at which you stop using Doctrine for the read path at all. Running the dashboard off a single SELECT * FROM post_list_read_model WHERE tenant_id = ? is dramatically faster than any hydration strategy, and the write-path complexity is usually tolerable.

The rule of thumb I use: if a read path loads more than 20 entities and is executed more than 100 times per minute, it should not use object hydration. Promote it to DTO or read-model form.

2. Know what the entity manager costs

The second pattern is about writes, not reads. Doctrine’s unit of work is excellent. It is also expensive. Every managed entity carries:

  • An entry in the identity map.
  • A snapshot of its state at load time, for change detection.
  • Potentially an entry in the scheduled-insert, scheduled-update, or scheduled-delete queues.
  • Lifecycle callback registrations.

For a request that loads one entity, modifies it, and flushes, none of this matters. For a batch job that processes 10,000 entities in a loop, all of it matters.

Two specific patterns come up in almost every engagement.

The batch job that runs out of memory. Classic Doctrine gotcha. The loop loads an entity, modifies it, maybe flushes, and moves on. After 5,000 iterations, the process has a 2 GB heap and is visibly slowing down. The identity map is holding every entity ever loaded. Change tracking is computing diffs on all of them.

The fix is canonical. Batch the work, flush periodically, and clear the manager:

PHP
$batchSize = 100;
$i = 0;

$query = $this->em->createQuery('SELECT p FROM App\Entity\Post p');

foreach ($query->toIterable() as $post) {
    $post->regenerateSlug();

    ++$i;

    if (0 === $i % $batchSize) {
        $this->em->flush();
        $this->em->clear();
    }
}

$this->em->flush();
$this->em->clear();

Three things to note. First, toIterable() (which replaced the deprecated iterate()) streams results instead of loading the full set into memory. Second, clear() detaches everything from the manager; if you hold references to entities across the clear, you will get errors. Third, the batch size is tunable. 100 is a starting point; profile with realistic data.

The hot-path entity load nobody notices. On a high-frequency endpoint, a single $this->em->getRepository(User::class)->find($id) call is ~1 ms of framework cost on top of the SQL. That adds up. If the endpoint is called 10,000 times per second and the user data fits in Redis, the right answer is not “make Doctrine faster,” it is “do not go through Doctrine on this path.” Cache the projection. Read the DTO from the cache. Go to Doctrine only on writes or cache misses.

The instinct to put everything through the ORM is a convenience, not a requirement. The hottest 10 percent of your endpoints can skip it entirely without architectural damage. Identify them by looking at request volume, not by instinct.

3. Explicit joins beat fetch="EAGER" (almost) every time

fetch="EAGER" on an association feels like the right tool when the relation is always needed. “The Post always has an Author, so always load the Author.” Then you add it, and performance gets worse, not better. Why?

Because EAGER is applied at the load-strategy level. Every time you load a Post (including from inside a loop, including via find(), including via the identity map), Doctrine issues a separate query for the Author if it is not already loaded. That is the opposite of what you wanted.

The right pattern is fetch="LAZY" (the default) combined with explicit JOIN FETCH at the query site, where you know whether the relation is needed.

PHP
$posts = $this->em->createQuery(
    'SELECT p, a
       FROM App\Entity\Post p
       JOIN FETCH p.author a
      WHERE p.status = :status'
)->setParameter('status', 'published')->getResult();

Now the author is loaded in the same SQL query as the post. On the list page where you need it, one query. On the detail endpoint where you do not, the lazy default applies and nothing extra is loaded.

I have yet to see a project where EAGER fetch modes were the right call across the board. I have seen many where turning them off and adding explicit joins cut page render time by a third.

Two related traps to watch for:

fetchEager on @ManyToMany relations. Almost always a disaster. A Post with eager-loaded tags, loaded in a list of 50 posts, fires 50 tag queries unless every post’s tags happen to be in the identity map. Switch to lazy, join explicitly when needed, use array hydration for the list view.

fetch="EXTRA_LAZY" for counting. This is the one place where an eager-ish mode is usually correct. EXTRA_LAZY on a Collection lets you call $post->getComments()->count() without loading every comment, because Doctrine resolves it to a SELECT COUNT(*). For counting-heavy pages (badges, notification bells), this is the right pattern.

4. Use the second-level cache carefully, or not at all

Doctrine’s second-level cache is the feature that looks most like a silver bullet and behaves least like one. The promise: cache hydrated entities in Redis, skip the database on repeat reads. The reality: cache invalidation is hard, the cache key strategy is subtle, and the failure mode when it goes wrong is stale data served quietly for hours.

I tell most teams not to use the second-level cache unless they have a specific, measured case. The cases where it earns its keep:

  • A read-heavy entity that changes rarely (site settings, tax rates, configuration, reference data).
  • A slow-query path where the query is expensive and the result set is small.
  • A read path where the staleness tolerance is explicit and measured in minutes, not seconds.

The cases where it hurts more than it helps:

  • Any entity where “correct” means “current to the millisecond.”
  • Any entity with a large graph of relations, because the cache has to track association invalidation and often gets it wrong.
  • Any cache-through pattern where the application does not own the invalidation path (say, a sibling service writes to the same table).

If you use it, use it narrowly and intentionally:

PHP
#[ORM\Entity]
#[ORM\Cache(usage: 'READ_ONLY', region: 'settings')]
class Setting
{
    // ...
}

READ_ONLY is the safest tier. Doctrine will raise an exception if you try to mutate a cached entity, which forces you to decide explicitly whether this entity type ever changes at runtime. NONSTRICT_READ_WRITE is more flexible and much more dangerous. READ_WRITE provides coherent invalidation but at a cost that often negates the benefit.

A cheaper and more predictable alternative: maintain your own cache layer above the repository, with a clear invalidation contract you control. You lose the “transparent” magic. You gain the ability to reason about staleness.

5. Profile with numbers, not vibes

The fifth pattern is a meta-pattern, but it is the one without which the other four do not compound.

You will make performance changes based on hunches. Your hunches will be wrong some of the time. Without numbers, you will not know which ones were wrong, and you will ship “improvements” that regress.

Three tools earn their place in the workflow.

Symfony Profiler for development. The Doctrine panel shows every query for a request, its time, and its parameters. Before you touch a page, record what it does now. After your change, record what it does. If the query count is the same and the total time is the same, you did not improve anything, regardless of how much cleaner the code looks.

Blackfire for targeted investigation. When you need to know where the time actually goes, a Blackfire profile is worth an hour of reading code. I have watched senior engineers spend days optimizing hydration when the real bottleneck was JSON serialization, or spend days optimizing a query when the real bottleneck was a listener running on every flush. Blackfire tells you the truth fast.

OpenTelemetry for production. What happens on your laptop with 100 rows of dev data is not what happens in production with 5 million rows under concurrent load. Instrument the app with OpenTelemetry, export the traces somewhere you can query, and look at p95 and p99 latencies for the paths you care about. Averages will lie to you. Percentiles will tell you what your slowest 5 percent of users actually experience.

The rule: no performance claim goes into a PR description without a before and after measurement. “Improved query performance” is not a PR summary. “Reduced post-list p95 latency from 420 ms to 180 ms (5,000 rows, staging)” is.

The patterns I almost always see misused

A short list of anti-patterns that masquerade as optimizations:

Adding fetch="EAGER" to fix an N+1. Papers over the problem by applying the wrong tool. The right tool is an explicit join at the query, or a DTO projection.

Using @Cache annotations everywhere as “just in case.” Now your cache semantics are unclear everywhere and failures are silent. Remove them; add them back only when you have a measured reason.

persist() on already-managed entities. Harmless, but a tell that the code author is not sure of the object state. Read the identity map before you persist.

Flushing inside a loop. Unless you are deliberately batching, a flush per iteration kills throughput. Accumulate, flush once. If you are batching, flush every N and clear.

Using the QueryBuilder when a DQL string would be shorter and clearer. QueryBuilder earns its place for dynamic queries (filters, optional joins). For static queries, a DQL string is easier to read, easier to optimize, and less code.

A sequenced plan for a Doctrine performance engagement

Put the patterns together into a week-long engagement, and it looks like this.

Day 1: Measure. Pull Blackfire profiles on the top 10 endpoints by request volume. Record p50, p95, and p99 latencies for each. Count Doctrine queries per request. This is the baseline. Do not touch code today.

Day 2: Audit read paths. For each of the top 10 endpoints, classify as read-only or read-write. For the read-only ones, identify which still use object hydration and whether there is a clear DTO projection opportunity. Write down the candidates.

Day 3: Audit writes. Look at the batch jobs and high-volume write paths. Identify any that do not batch and clear. Identify any that flush inside a loop. Identify any that iterate over a large result set.

Day 4: Fix the top three read paths. Convert object hydration to DTO hydration. Measure before and after with Blackfire and production-like data. Ship. No other changes in the same PR.

Day 5: Fix the top three write paths. Introduce batching and clearing. Measure throughput change on a realistic dataset. Ship.

At the end of the week, you should have meaningful, measured improvements on six code paths, with before-and-after numbers attached to each PR. That is what fundable performance work looks like.

When Doctrine is not the answer

The ORM is a sharp tool. It is also a general-purpose tool, and general-purpose tools lose to specialized ones at the edges. If your workload looks like:

  • A read-mostly system with highly denormalized access patterns.
  • A reporting engine running aggregate queries over millions of rows.
  • A high-throughput event ingest that writes then forgets.

Doctrine is probably not earning its keep on that path. For the first, a dedicated read-model table or a materialized view is faster. For the second, a query tool that knows about columnar data (DuckDB for embedded, ClickHouse for server-side) will crush Doctrine by an order of magnitude. For the third, you want raw SQL inserts or a message-bus pattern, not a managed entity graph.

Knowing when to leave the ORM is part of mastering it. The wins from picking the right tool per path are bigger than any amount of hydration tuning.


If you are looking at a Symfony application that is slowing down under load and the N+1 fixes are not moving the numbers anymore, my scaling engagement starts with a measured Doctrine audit. A week on the hottest ten endpoints, before-and-after numbers in every PR, and a catalog of the read paths that should exit the ORM entirely.

References

Ready to Fix Your Architecture?

Book a free 30-minute call with Silas. No sales pitch, just a direct conversation about your challenges.

Typically responds within 24 hours.

Book a Free Call