09/02/2026
The N+1 you wrote in a Twig loop, and its fix
This controller looks fine:
$articles = $entityManager->getRepository(Article::class)->findAll();
return $this->render('article/index.html.twig', ['articles' => $articles]);
This template looks fine:
{% for article in articles %}
<h2>{{ article.title }}</h2>
<p>by {{ article.author.name }}</p>
{% endfor %}
Together, with fifty articles, they issue fifty-one queries. One for
the articles, and then one more for each article.author.name the
template reaches for.
Why the template is where it happens
A ManyToOne association is lazy by default. findAll() fetches
articles and leaves each author as a proxy object — a placeholder that
knows its id and nothing else. The proxy stays cheap right up until
somebody asks it a question, and then it goes to the database.
Nothing in the controller asks. {{ article.author.name }} asks, once per
iteration, long after the code that chose the query has finished running.
That is what makes this hard to spot by reading either file: neither one
is wrong on its own.
Seeing it
The Symfony profiler counts queries per request. Open the Doctrine panel on a page you suspect and look at the number rather than the time — fifty queries of two milliseconds each is a page that feels acceptable in development and falls over the moment the database is a network hop away rather than a socket.
A row count and a query count that move together is the signature. Add ten articles, watch the query count go up by ten.
The fix: select the association, not just join it
A join alone does not help. Doctrine will happily join for filtering and still leave the association lazy — the fix is to also select the joined alias, which is what puts the author objects into the result and the identity map:
public function findAllWithAuthors(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.author', 'author')
->addSelect('author')
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
addSelect('author') is the whole fix. Without it you have a join and
fifty-one queries; with it, one.
The template does not change. It could not tell the difference before and cannot now — which is the point, and also why this keeps happening.
A leftJoin, not a join
leftJoin matters here more than it usually does. An inner join drops
every article whose author is null, so switching to a fetch join can
silently shorten the list you were rendering. If the association is
genuinely optional, leftJoin keeps the rows.
Where a fetch join is the wrong answer
Selecting a OneToMany alongside its parent multiplies rows — fifty
articles with twenty comments each is a thousand-row result being
hydrated into fifty objects, and Doctrine also cannot apply
setMaxResults() correctly to a query that fetch-joins a collection.
For a to-many association, either accept the extra query, or use
Doctrine\ORM\Tools\Pagination\Paginator, which runs the two-step query
that gets both the limit and the collection right.
The rule that catches it early
Any {{ thing.other.property }} inside a {% for %} is a database round
trip per iteration unless the query said otherwise. It is worth reading
templates with that specifically in mind, because the controller will
never tell you.