HEAPS

Doctrine Performance in Symfony: Fix Slow Queries Before You Scale

Practical Doctrine ORM tips for Symfony apps: stop N+1 queries, use DQL and QueryBuilder wisely, lean on indexes, and measure before you rewrite your data layer.

Slow Symfony apps are often slow databases in disguise

When a Symfony page feels heavy, teams reach for more FPM workers, a bigger server, or a cache layer. Those can help. Many times the real cost sits in Doctrine: too many queries per request, oversized hydration, missing indexes, or repositories that load half the catalog to render ten products.

This guide focuses on changes that usually give the best return before you introduce Elasticsearch, CQRS, or a rewrite.

Measure first with the Symfony profiler

Guessing is expensive. Open the Symfony profiler on a slow page and check:

  1. How many SQL queries ran.
  2. Which query took the longest.
  3. Whether the same SELECT repeats in a loop.
  4. How large the hydrated objects are.

In automated tests or staging, Doctrine SQL logging and Blackfire / Tideways profiles catch regressions early. Fix the top offenders first. A single N+1 in a listing page often beats a week of micro optimizations elsewhere.

Kill the N+1 pattern

The classic trap looks innocent:

$orders = $orderRepository->findBy(['status' => 'paid']);

foreach ($orders as $order) {
    echo $order->getCustomer()->getEmail();
}

If customer is lazy loaded, each loop iteration may fire another query. Fix it with an explicit join and addSelect:

public function findPaidWithCustomer(): array
{
    return $this->createQueryBuilder('o')
        ->addSelect('c')
        ->innerJoin('o.customer', 'c')
        ->andWhere('o.status = :status')
        ->setParameter('status', 'paid')
        ->getQuery()
        ->getResult();
}

For collections, the same idea applies with leftJoin and careful DISTINCT only when duplication actually appears.

Prefer specific queries over findAll() habits

Repositories that return full entities everywhere become hard to optimize. For list screens, fetch only what the view needs:

public function listProductCards(int $limit = 20): array
{
    return $this->createQueryBuilder('p')
        ->select('p.id', 'p.name', 'p.slug', 'p.price')
        ->andWhere('p.active = true')
        ->orderBy('p.createdAt', 'DESC')
        ->setMaxResults($limit)
        ->getQuery()
        ->getArrayResult();
}

getArrayResult() or a DTO hydrator avoids building a heavy object graph when you only render cards. Keep full entities for write paths and detail pages where you truly need the domain model.

Use indexes that match real filters

Doctrine mappings do not replace database design. If you filter orders by status and date, add a composite index that mirrors that access pattern:

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_orders_status_created')]
class Order
{
    // ...
}

Then verify with EXPLAIN. An index that looks good in theory but does not match WHERE and ORDER BY clauses still leaves sequential scans on large tables.

Batch writes and avoid identity map bloat

Import jobs and mass updates should not keep thousands of entities in memory:

$batchSize = 100;
$i = 0;

foreach ($rows as $row) {
    $product = new Product($row['sku'], $row['name']);
    $em->persist($product);

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

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

clear() resets the identity map so memory stays flat. Without it, long CLI commands climb until the worker dies.

Second level cache and HTTP cache are different tools

Doctrine second level cache can help read heavy reference data that rarely changes (countries, tax rates, feature flags). It will not rescue a badly designed product listing.

For pages, prefer HTTP caching, Symfony HttpCache, or a reverse proxy once the HTML or JSON is already cheap to build. Cache layers amplify good queries. They also amplify bad ones if you cache stampeding misses.

Practical checklist before every release

  1. Profile the hottest pages and API endpoints.
  2. Ensure listings use joins or dedicated read queries.
  3. Confirm indexes exist for new filters and sorts.
  4. Cap pagination and forbid unbounded exports in web requests.
  5. Move heavy reports to Messenger workers or a read replica.

Conclusion

Doctrine is not slow by default. Unbounded repositories, lazy loading in loops, and missing indexes are. Treat the Symfony profiler as part of feature work, not as an emergency tool. Most Symfony applications get a visible speed win from cleaner queries long before they need a new architecture.