DEV Community

Cover image for Stop N+1 Queries Forever: Advanced Doctrine ORM Strategies in Symfony 8.1
Matt Mochalkin
Matt Mochalkin

Posted on

Stop N+1 Queries Forever: Advanced Doctrine ORM Strategies in Symfony 8.1

The N+1 query problem is the silent killer of application performance. You build a beautiful new Symfony endpoint, test it locally with a handful of database records and the response time is a lightning-fast 30ms. You deploy to production and suddenly, that same endpoint takes 3 seconds to load, choking the database connection pool and bringing your application to its knees.

If you check the Symfony Web Profiler, you’ll see a wall of identical SELECT statements varying only by an ID. You’ve been hit by the N+1 query problem.

In this guide, we are moving past the beginner advice of “just use joins.” We need to leverage the full power of Doctrine ORM 3.x in Symfony 8.1. We will explore custom Doctrine Query Language (DQL), precise Fetch Modes, Partial Objects and Data Transfer Object (DTO) hydration to guarantee high-performance, scalable endpoints.

The Core Concept - What is N+1?

An N+1 problem occurs when an Object-Relational Mapper (ORM) executes one query to retrieve a list of “N” entities (the “1” query) and then executes an additional query for each entity to retrieve its related data (the “N” queries). If you fetch 100 Author entities and iterate over them to get their Book entities, Doctrine defaults to lazy loading, resulting in 101 queries instead of 1.

Prerequisites and Setup

To follow along with these advanced strategies, ensure your environment meets the following specifications:

  • PHP: 8.2 or 8.3
  • Symfony: 8.1

Packages:

  • doctrine/orm (v3.1+)
  • doctrine/doctrine-bundle (v2.12+)
  • symfony/profiler-pack (v3.3+) — Crucial for verification.

Strategy 1: The Golden Standard — DQL and JOIN FETCH

The most reliable way to eliminate N+1 queries is explicitly telling Doctrine to fetch the primary entity and its relations in a single database round-trip. This is done using JOIN FETCH in DQL or the QueryBuilder.

Standard JOIN in DQL filters the results but does not hydrate the related entities into the primary entity. JOIN FETCH does both.

The Code: Implementing JOIN FETCH

Let’s assume we have an Author entity and a Book entity.

namespace App\Entity;

use App\Repository\AuthorRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AuthorRepository::class)]
class Author
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    /**
     * @var Collection<int, Book>
     */
    #[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author', cascade: ['persist', 'remove'])]
    private Collection $books;

    public function __construct()
    {
        $this->books = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Collection<int, Book>
     */
    public function getBooks(): Collection
    {
        return $this->books;
    }

    public function addBook(Book $book): static
    {
        if (!$this->books->contains($book)) {
            $this->books->add($book);
            $book->setAuthor($this);
        }

        return $this;
    }

    public function removeBook(Book $book): static
    {
        if ($this->books->removeElement($book)) {
            // set the owning side to null (unless already changed)
            if ($book->getAuthor() === $this) {
                $book->setAuthor(null);
            }
        }

        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

If we want to load all authors and their books, we write a custom repository method:

namespace App\Repository;

use App\DTO\AuthorBookSummary;
use App\Entity\Author;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Author>
 */
class AuthorRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Author::class);
    }

    /**
     * Strategy 1: JOIN FETCH
     * Fetches all authors and eagerly loads their associated books.
     * @return Author[]
     */
    public function findAllWithBooks(): array
    {
        return $this->createQueryBuilder('a')
            ->select('a', 'b') // We MUST select both 'a' and 'b' to hydrate the relation
            ->leftJoin('a.books', 'b')
            ->getQuery()
            ->getResult();
    }
...
}
Enter fullscreen mode Exit fullscreen mode

By passing both ‘a’, ‘b’ to select() and joining a.books as b Doctrine generates a single SQL query:

SELECT a0_.id AS id_0, a0_.name AS name_1, b1_.id AS id_2, b1_.title AS title_3, b1_.author_id AS author_id_4 
FROM author a0_ 
LEFT JOIN book b1_ ON a0_.id = b1_.author_id
Enter fullscreen mode Exit fullscreen mode

When Doctrine hydrates the Author objects, it sees the Book data in the result set and pre-populates the $books collection. No lazy loading will be triggered.

Strategy 2: Fetch Modes via Attributes

Sometimes you know that a specific relation will always be accessed when the parent entity is loaded. In these cases, you can alter the default fetch mode at the entity level using PHP 8 Attributes.

Doctrine provides three fetch modes:

  • LAZY (Default): Fetches related data only when accessed.
  • EAGER: Always fetches the related data alongside the parent entity.
  • EXTRA_LAZY: Optimized for collections (e.g., count($author->getBooks()) issues a COUNT() query instead of loading the whole collection).

Forcing Eager Loading

If every time we load a Book, we need to know its Author, we can set the ManyToOne relation to EAGER.

namespace App\Entity;

use App\Repository\BookRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: BookRepository::class)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    // Setting fetch mode to EAGER
    #[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books', fetch: 'EAGER')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Author $author = null;

    // ... getters and setters
}
Enter fullscreen mode Exit fullscreen mode

Do not overuse EAGER fetching. If you set too many relations to EAGER, Doctrine will generate massive, complex queries (or multiple background queries depending on the relation type) every time you fetch a single entity, resulting in memory exhaustion. Reserve EAGER strictly for relations that are universally required by the application business logic.

Strategy 3: Precision Optimization with Partial Objects

One major source of overhead in ORMs is hydration — converting database rows into PHP objects. If an Author entity has 30 columns (bio, birthdate, profile picture URL, etc.) but your endpoint only needs to display a list of Author names and Book titles, hydrating the full object wastes CPU and memory.

Doctrine allows you to load Partial Objects. This bypasses full entity hydration and only populates the specific fields you request.

Using the partial Keyword

// src/Repository/AuthorRepository.php

    /**
     * @return Author[]
     */
    public function findPartialAuthorsWithPartialBooks(): array
    {
        return $this->createQueryBuilder('a')
            // Using the 'partial' keyword to only select ID and Name
            ->select('partial a.{id, name}')
            ->addSelect('partial b.{id, title}')
            ->leftJoin('a.books', 'b')
            ->getQuery()
            ->getResult();
    }
Enter fullscreen mode Exit fullscreen mode

The Danger of Partial Objects

Doctrine issues a strict warning regarding partial objects - they should be considered read-only.
If you load a partial Author object, modify its name and call EntityManager::flush() Doctrine’s Unit of Work might behave unpredictably regarding the omitted fields, potentially nullifying them in the database.

Only use partial objects for heavily-trafficked read-only APIs or reports.

Strategy 4: The Ultimate Read-Only Fix — DTO Hydration

If you are building a high-traffic API endpoint, hydrating ORM entities — even partial ones — is often unnecessary overhead. You don’t need the Doctrine Unit of Work tracking changes on objects you only intend to serialize to JSON.

Instead, construct custom Data Transfer Objects (DTOs) directly in your DQL using the NEW operator.

Direct DTO Hydration

First, create a simple, immutable DTO using PHP 8.2+ readonly classes.

namespace App\DTO;

readonly class AuthorBookSummary
{
    public function __construct(
        public int $authorId,
        public string $authorName,
        public int $bookId,
        public string $bookTitle
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Next, use the NEW syntax in your Repository to instantiate these DTOs straight from the query result.

namespace App\Repository;

use App\DTO\AuthorBookSummary;

// ...

    /**
     * @return AuthorBookSummary[]
     */
    public function getAuthorBookSummaries(): array
    {
        return $this->createQueryBuilder('a')
            ->select(sprintf(
                'NEW %s(a.id, a.name, b.id, b.title)',
                AuthorBookSummary::class
            ))
            ->join('a.books', 'b')
            ->getQuery()
            ->getResult();
    }
Enter fullscreen mode Exit fullscreen mode
  • Zero N+1 Queries: The data is pulled in a single join.
  • Zero Unit of Work Overhead: Doctrine does not track these DTOs for changes.
  • Low Memory Footprint: You avoid instantiating Doctrine Proxy classes, ArrayCollection instances and full entities.

Strategy 5: The Hidden N+1 in Iterators (Iterator N+1)

When dealing with massive datasets — like exporting thousands of records or processing batch updates via a Symfony Console Command — loading all entities into memory at once with getResult() will cause a fatal out-of-memory error.

To solve this, developers correctly turn to Doctrine’s toIterable() method (the modern replacement for the old iterate() method in Doctrine 3.x). This yields results one by one, keeping the memory footprint low. However, this introduces a highly dangerous, hidden N+1 trap.

Iterating over Lazy Relations

Imagine a background command that exports authors and their books. If you iterate over the Author entities but fail to eagerly load the Book relation, Doctrine will fire a new query for the books on every single iteration.

protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // 🚨 THE TRAP: We select 'a' but do not JOIN FETCH 'a.books'
        $query = $this->authorRepository->createQueryBuilder('a')->getQuery();

        $batchSize = 100;
        $i = 0;

        // toIterable() loads one Author at a time into memory
        foreach ($query->toIterable() as $author) {
            // 💥 N+1 TRIGGERED: Accessing $author->getBooks() fires a new query!
            foreach ($author->getBooks() as $book) {
                // Do something with the book...
            }

            // Detach entities to prevent memory leaks in the Unit of Work
            if (($i % $batchSize) === 0) {
                $this->entityManager->clear();
            }
            $i++;
        }

        return Command::SUCCESS;
    }
Enter fullscreen mode Exit fullscreen mode

If you have 50,000 authors, this loop will execute 50,001 queries, completely nullifying the performance benefits of the iterator and grinding your background worker to a halt. You could fix this with a JOIN FETCH but if you are just exporting data, there is a much better, memory-safe way.

Strategy 6: High-Performance Iterators with DTOs (Iterator DTO)

To achieve the ultimate combination of low memory usage and zero N+1 queries during batch processing, we can combine the toIterable() method with the direct DTO hydration (NEW) strategy we discussed in Strategy 4.

By yielding Data Transfer Objects instead of Doctrine Entities, you bypass the Unit of Work entirely. You don’t need to worry about calling $entityManager->clear() in batches because Doctrine isn’t tracking the DTOs and the data is fetched in a single optimized query stream.

Yielding DTOs from an Iterator

Let’s refactor our AuthorRepository and our background command to use this approach. We will reuse the AuthorBookSummary DTO from earlier.

    public function getAuthorBookSummariesIterable(): iterable
    {
        return $this->getEntityManager()->createQueryBuilder()
            ->select(sprintf(
                'NEW %s(a.id, a.name, b.id, b.title)',
                AuthorBookSummary::class
            ))
            ->from('App\Entity\Book', 'b')
            ->join('b.author', 'a')
            ->getQuery()
            ->toIterable();
    }
Enter fullscreen mode Exit fullscreen mode
  • Constant Memory Footprint: PHP streams the results from the database. You will use roughly the same amount of RAM processing 10 rows as you would processing 1,000,000 rows.
  • Absolute Zero N+1 Queries: The JOIN in the DQL ensures the database does the heavy lifting, delivering flat, predictable rows.
  • CPU Efficiency: Because Doctrine doesn’t have to create Proxy classes or maintain state tracking for the Unit of Work, your script execution time will drop dramatically.

Proving You Fixed the Problem

Writing the code is only half the battle, we must prove our optimizations work. Symfony provides the ultimate tool for this: The Profiler.

Install the Profiler

If you haven’t already, install the profiler pack in your dev environment:

composer require --dev symfony/profiler-pack
Enter fullscreen mode Exit fullscreen mode

Analyze the Database Tab

  1. Load your endpoint in the browser.
  2. Click on the Doctrine icon in the Symfony Web Debug Toolbar (bottom of the screen).
  3. Look at the Query Metrics.
  4. Before Optimization: You will see a high number of queries (e.g., “102 queries”) and warnings in yellow about “Similar Queries” or “Identical Queries.”
  5. After Optimization: You should see exactly 1 query executed for the endpoint, with a substantially lower execution time.

Automated Testing for N+1

You can integrate query counting into your functional tests to prevent regressions.

namespace App\Tests\Functional;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class AuthorApiTest extends WebTestCase
{

    public function testAuthorsNPlusOneProblem(): void
    {
        $queryCount = $this->getQueryCountForEndpoint('/api/authors/n-plus-one');

        // With 10 authors, we expect 1 query for authors + 10 queries for books = 11 queries
        $this->assertGreaterThan(1, $queryCount, 'N+1 query problem should be present in the non-optimized endpoint');
        $this->assertEquals(11, $queryCount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Benchmarking the Strategies

In software engineering, we don’t just guess — we measure. To prove the massive impact of these optimizations, let’s look at a performance benchmark comparing all the strategies discussed above.

This test simulates fetching a collection of primary entities and their relations, measuring CPU time, allocated memory (RAM) and average execution time.

+--------------------+-----------+-------------+------------+------------------------------------------------------------+
| Strategy Name      | CPU (avg) | RAM (alloc) | Time (avg) | Summary                                                    |
+--------------------+-----------+-------------+------------+------------------------------------------------------------+
| 1. N+1 Problem     | 1.90 ms   | 0.1607 MB   | 2.14 ms    | Baseline: 1 query for Author, N queries for Books.         |
| 2. JOIN FETCH      | 1.37 ms   | 0.0741 MB   | 1.43 ms    | Eager loads collections in 1 query via DQL.                |
| 3. Eager Fetch     | 1.03 ms   | 0.0714 MB   | 1.03 ms    | Eager loading relations dynamically using query builder.   |
| 4. Partial Objects | 1.14 ms   | 0.0741 MB   | 1.14 ms    | Hydrates only specific fields, reducing memory.            |
| 5. DTO Hydration   | 0.36 ms   | 0.0084 MB   | 0.37 ms    | Bypasses ORM entities entirely. Fastest array fetch.       |
| 6. Iterator (N+1)  | 1.56 ms   | 0.1615 MB   | 1.57 ms    | Streams entities, but lazy-loading relations triggers N+1. |
| 7. Iterator (DTO)  | 0.37 ms   | 0.0081 MB   | 0.37 ms    | Streams DTOs. Lowest RAM usage for massive exports.        |
+--------------------+-----------+-------------+------------+------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

The numbers speak for themselves, revealing some critical insights for your application’s architecture:

  1. The Baseline is Expensive: The standard N+1 scenario is the slowest (2.14 ms) and uses the most memory (0.1607 MB) because Doctrine has to instantiate objects, manage the Unit of Work and execute multiple round-trips to the database.
  2. JOIN FETCH is the Easy Win: Simply adding a JOIN FETCH (Strategy 1) cuts memory usage by more than half and speeds up the response time by ~33%. It’s the most effective fix for standard entity workflows.
  3. The DTO Dominance: Look at Strategies 4 and 6. Bypassing entity hydration entirely and yielding Data Transfer Objects drops memory usage to an incredibly lean 0.008 MB and execution time to a blazingly fast ~0.37 ms. This is an 80%+ reduction in response time and a 95% reduction in memory usage compared to the baseline.
  4. The Iterator Trap: Strategy 5 proves that simply using an iterator doesn’t save you if you still have an N+1 problem hiding inside the loop. It consumed nearly as much memory as the baseline and was heavily bottlenecked by CPU overhead.

If you are building an API endpoint or a heavy background export command, DTOs are the undisputed champions of Doctrine performance. Combine them with iterators (Strategy 6) and your Symfony 8.1 application will scale effortlessly, regardless of how large your database grows.

Conclusion

The N+1 query problem is a rite of passage for every PHP developer but it doesn’t have to dictate your application’s performance. As the benchmarks clearly show, the difference between a sluggish, memory-hungry endpoint and a lightning-fast API often comes down to just a few carefully crafted lines of Doctrine Query Language.

You now have a complete, advanced toolkit for Symfony 8.1 to completely eradicate N+1 queries from your projects:

  1. For Standard Workflows: Default to JOIN FETCH (Strategy 1) to explicitly eager-load related data in a single round-trip when your business logic requires full entities.
  2. For Universal Relations: Use EAGER fetch modes via PHP 8 Attributes (Strategy 2) sparingly, reserving them strictly for data that is universally required across your entire application.
  3. For Read-Only Views: Leverage Partial Objects (Strategy 3) to cut down on hydration overhead when you only need a handful of entity properties.
  4. For High-Traffic APIs: Switch to direct DTO Hydration using the NEW keyword (Strategy 4) to bypass the Doctrine Unit of Work entirely, resulting in massive speed improvements.
  5. For Massive Datasets: Combine the toIterable() method with DTOs (Strategy 6) to process thousands of records with a near-zero memory footprint and absolute zero N+1 queries.

Always remember to lean heavily on the Symfony Web Profiler. Never guess at your database performance — measure your query counts, implement these optimizations and verify the results. By applying these advanced Doctrine ORM 3.x strategies, you are doing more than just fixing bugs; you are engineering robust, highly scalable Symfony applications that will thrive under heavy traffic.

Source Code: You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/DQLStrategies]

Let’s Connect!

If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:

LinkedIn: [https://www.linkedin.com/in/matthew-mochalkin/]
X (Twitter): [https://x.com/MattLeads]
Telegram: [https://t.me/MattLeads]
GitHub: [https://github.com/mattleads]

Top comments (0)