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.