Diagnosing and Fixing Doctrine N+1 Lazy‑Loading Issues in PHP Applications
When page load times spike due to repeated SELECTs, Doctrine’s lazy‑loading can create an N+1 problem. This guide walks through spotting the pattern, checking mappings, and applying fetch joins or eager loading to eliminate the extra queries.
17 Jun 2026, 15:42 UTC

Recognizable Condition
When a page that displays a list of entities suddenly takes several seconds to load, the first clue is usually an explosion of identical SELECT statements in the Doctrine SQL log. Each row of the list triggers a separate query to fetch an association that was marked LAZY by default.
Typical symptoms:
- Page load time increases linearly with the number of items.
- Doctrine’s profiler or a debug toolbar shows dozens or hundreds of identical
SELECTcalls for the same association. - The returned data appears correct, but the database is hammered with repeated queries.
Cause / Diagnostic Table
| Cause | Diagnostic Indicator |
|---|---|
| Missing fetch join in DQL / Query Builder | Each entity’s association is accessed after the initial query. |
Association mapped with fetch="LAZY" and never overridden | Doctrine logs a separate SELECT for each entity. |
Use of EXTRA_LAZY without batch loading | First access triggers a query; subsequent accesses still hit the DB. |
| Uninitialized collection accessed in a loop | Multiple SELECTs for the same collection. |
Ordered Checks
- Enable the SQL logger (e.g.,
$config->setSQLLogger(new \Doctrine\Common\Logging\DebugStack());) or use Symfony’s web profiler. Run the request and capture the log. - Count SELECT statements for the problematic association. In the log, look for lines like:
If you see dozens of such lines for the same table, you have an N+1.SELECT a.id AS id_1, a.name AS name_1 FROM author a WHERE a.id = 1 - Inspect the entity mapping. For example:
Check the/** * @ORM\OneToMany(targetEntity="Post", mappedBy="author", fetch="LAZY", cascade={"persist"}) */ private $posts;fetchattribute and whetherEXTRA_LAZYis enabled. - Review the query that retrieves the parent entities. If you use DQL:
or the query builder:SELECT u FROM User u
Notice that there’s no$qb = $em->createQueryBuilder() ->select('u') ->from(User::class, 'u');JOINfor thepostsassociation. - Search for explicit
$em->find()or repository calls that might bypass fetch joins. Ensure all data retrieval follows a consistent pattern.
Fixes Tied to Findings
1. Add a Fetch Join
When you need the association data, modify the DQL or query builder to include a LEFT JOIN FETCH:
// DQL
SELECT u, p FROM User u LEFT JOIN FETCH u.posts p
WHERE u.id = :id
// Query Builder
$qb = $em->createQueryBuilder()
->select('u', 'p')
->from(User::class, 'u')
->leftJoin('u.posts', 'p')
->where('u.id = :id')
->setParameter('id', $id);
This pulls the posts in the same SQL call, eliminating the per‑entity SELECTs.
2. Switch to EAGER Fetch (with caution)
If the association is always needed, you can change the mapping:
/**
* @ORM\OneToMany(targetEntity="Post", mappedBy="author", fetch="EAGER")
*/
private $posts;
Be aware that this will load the entire collection for every user, which can be costly if the collection is large or rarely accessed.
3. Use EXTRA_LAZY and Batch Loading
For large collections, EXTRA_LAZY can reduce memory usage, but it still triggers a query on first access. Combine it with batch fetching:
/**
* @ORM\OneToMany(targetEntity="Post", mappedBy="author", fetch="EXTRA_LAZY")
* @ORM\BatchSize(size=20)
*/
private $posts;
Doctrine will now load posts in chunks of 20 when the collection is iterated.
4. Leverage the Second‑Level Cache
Enabling the second‑level cache for the association can serve repeated requests from memory:
// In orm.yaml
orm:
second_level_cache:
region: default
metadata_cache_driver: pool.doctrine.system_cache
result_cache_driver: pool.doctrine.system_cache
query_cache_driver: pool.doctrine.system_cache
enabled: true
After configuring, add @Cacheable annotations to the entity or use cache regions in the mapping.
Escalation Criteria
- If after adding fetch joins the SELECT count remains high, search for uninitialized proxies being accessed elsewhere in the code (e.g., in a template that loops over
$user->getPosts()after the main query). - Check for circular references that cause Doctrine to re‑initialize proxies during serialization or debugging.
- Consider using
SELECT partialto fetch only the fields you need, reducing payload size. - If performance does not improve, review the Doctrine version: some
EXTRA_LAZYfeatures require 2.5 or newer.
Verification Steps
- Re‑enable the SQL logger and replay the request. The number of SELECTs for the problematic association should drop to one or a small batch count.
- Run a unit test that fetches a known set of users and asserts that the
postscollection is populated correctly after the fix. - Measure page load time and CPU usage before and after the change. A noticeable reduction indicates success.
Practical Checklist
- Enable SQL logger.
- Identify repeated SELECTs.
- Check mapping
fetchattribute. - Add
JOIN FETCHor adjust mapping. - Re‑run the request and confirm SELECT count drop.
- Validate data integrity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.