Le problème N+1 est la première cause de dégradation de performance que je rencontre lors des audits de projets Symfony. Il est sournois : invisible en développement sur des fixtures de vingt entrées, il explose dès que la base de données grossit. Un listing de 500 commandes génère 501 requêtes SQL, un tableau de bord relationnel en produit 1 200. J'ai vu des endpoints API répondre en 3 secondes alors qu'ils pouvaient tenir en 80 ms — la différence se jouait entièrement dans des proxies Doctrine jamais initialisés, parcourus silencieusement dans une boucle. Avec Doctrine ORM 3 et la restauration de SELECT PARTIAL dans la version 3.3.0 du 14 octobre 2024, on dispose aujourd'hui d'un arsenal complet pour traiter ce problème méthodiquement. Voici comment le déployer.
Le N+1 expliqué : une requête mère, N requêtes filles
Le N+1 se produit quand tu charges une collection d'entités — c'est la requête « 1 » — puis que tu accèdes à une association de chaque entité dans une boucle — ce sont les « N » requêtes. Doctrine, en mode LAZY par défaut, n'exécute pas de JOIN tant que tu n'accèdes pas à la relation : il injecte un proxy à la place. C'est excellent pour éviter le sur-chargement, mais catastrophique si tu parcours ensuite cette relation dans un foreach. Chaque accès à un proxy non initialisé déclenche un SELECT individuel en base. Sur 200 commandes, tu obtiens 201 requêtes SQL — l'exemple ci-dessous l'illustre.
// src/Entity/Order.php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
class Order
{
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: false)]
private Customer $customer;
/** @var Collection<int, OrderItem> */
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order')]
private Collection $items;
public function getCustomer(): Customer { return $this->customer; }
}
// --- Dans un controller ou un service ---
$orders = $orderRepository->findAll(); // 1 requête SELECT sur orders
foreach ($orders as $order) {
// Chaque appel déclenche 1 SELECT sur customers si la relation est LAZY
echo $order->getCustomer()->getName();
}
// Résultat sur 200 commandes : 201 requêtes SQLLAZY vs EAGER : pourquoi les deux stratégies globales sont des pièges
Doctrine ORM charge toutes les associations en LAZY par défaut : la relation n'est pas résolue en SQL tant que tu n'accèdes pas à l'objet lié. EAGER fait l'inverse : il charge immédiatement la relation avec un JOIN automatique à chaque requête sur l'entité parente. Les deux stratégies au niveau de l'entité sont des pièges symétriques. LAZY génère des N+1 silencieux dès qu'on parcourt la relation en boucle ; EAGER surcharge systématiquement même quand la relation n'est pas utilisée. La vraie solution n'est ni l'un ni l'autre au niveau du mapping, mais un contrôle fin au niveau de chaque requête.
- LAZY (défaut) — aucune requête supplémentaire si tu n'accèdes jamais à la relation ; N requêtes si tu le fais en boucle
- EAGER — résout le N+1 sur le findAll() initial, mais joint même quand tu n'as pas besoin de la relation — surcharge garantie
- EXTRA_LAZY — pour les grandes collections : count() et contains() évitent de charger toute la collection en mémoire
- Fetch join ciblé par requête — la stratégie correcte : tu choisis explicitement quand joindre, sans modifier le mapping de l'entité
Fetch join DQL : ta première ligne de défense
Un fetch join DQL demande explicitement à Doctrine de joindre et d'hydrater la relation en une seule requête. C'est la technique la plus universelle : elle fonctionne quel que soit le mode de chargement par défaut de l'entité, elle se déclare au niveau de la requête — pas du mapping — et tu peux l'activer uniquement quand tu sais que tu vas parcourir la relation. Le résultat reste des objets PHP complets, compatibles avec l'UnitOfWork Doctrine si tu dois modifier les entités après lecture.
// src/Repository/OrderRepository.php
use Doctrine\ORM\EntityRepository;
class OrderRepository extends EntityRepository
{
/**
* Charge les commandes avec leur client et leurs lignes en une seule requête SQL.
* Élimine le N+1 sur customer et items.
*
* @return Order[]
*/
public function findPendingWithDetails(): array
{
return $this->createQueryBuilder('o')
->select('o', 'c', 'i') // Clé : on sélectionne les alias des relations
->innerJoin('o.customer', 'c') // JOIN hydraté sur Customer
->leftJoin('o.items', 'i') // JOIN hydraté sur OrderItem (optionnel)
->where('o.status = :status')
->setParameter('status', 'pending')
->orderBy('o.createdAt', 'DESC')
->getQuery()
->getResult();
}
}La clé se trouve dans le ->select('o', 'c', 'i') : tu sélectionnes les alias des relations, pas seulement l'entité racine. Sans cela, le JOIN filtre les résultats côté SQL mais Doctrine ne déclenche pas l'hydratation des objets liés — tu aurais un JOIN SQL, mais toujours des proxies LAZY sur customer et items, et donc toujours un N+1. En DQL pur, la syntaxe est identique : SELECT o, c, i FROM App\Entity\Order o INNER JOIN o.customer c LEFT JOIN o.items i WHERE o.status = :status.
Hydratation en tableau pour les lectures pures
Si tu utilises les données uniquement pour les lire — une API REST, un export CSV, un rendu Twig sans modifications — tu n'as aucune raison de créer des objets PHP complets. L'hydratation en tableau (HYDRATE_ARRAY) retourne des tableaux associatifs : pas de proxy Doctrine, pas d'UnitOfWork, pas de gestion d'état des entités. Sur des jeux de données larges, le gain mémoire et CPU est significatif. Sur des endpoints de listing e-commerce, j'ai mesuré 35 à 60 % de réduction du temps de traitement PHP pur après passage en hydratation tableau.
// src/Repository/ProductRepository.php
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
class ProductRepository extends EntityRepository
{
/**
* Lecture seule : retourne des tableaux associatifs, pas des objets Product.
* Aucun proxy, aucun UnitOfWork, empreinte mémoire minimale.
*
* @return array<int, array{id: int, name: string, price: string, categoryName: string}>
*/
public function findActiveForCatalog(): array
{
return $this->createQueryBuilder('p')
->select('p.id', 'p.name', 'p.price', 'c.name AS categoryName')
->leftJoin('p.category', 'c')
->where('p.active = true')
->orderBy('p.name', 'ASC')
->getQuery()
->getResult(Query::HYDRATE_ARRAY); // Tableaux associatifs, pas des entités
}
}Dans ce cas, tu sélectionnes des champs scalaires individuels, pas les alias complets p et c. Le résultat est un tableau plat : ['id' => 42, 'name' => 'Widget Pro', 'price' => '29.90', 'categoryName' => 'Accessoires']. Idéal pour alimenter un serializer, un normaliseur API Platform ou une réponse JSON directement — sans l'overhead de la couche objet Doctrine.
Besoin d'un expert Symfony ?
Réserver un appel →SELECT PARTIAL restauré dans Doctrine ORM 3.3.0
La version 3.3.0 de Doctrine ORM, sortie le 14 octobre 2024, marque le retour d'une fonctionnalité abandonnée lors de la migration vers ORM 3 : SELECT PARTIAL. Cette syntaxe DQL permet de charger des objets PHP partiellement hydratés : seuls les champs que tu spécifies sont sélectionnés côté SQL, mais tu récupères des instances de tes entités — pas des tableaux. C'est le compromis idéal quand tu as besoin de l'objet (pour appeler une méthode métier, le passer à un service qui attend une entité typée) mais pas de la totalité de ses colonnes. Sur une entité Product avec quinze champs dont des blobs de description et des JSON de métadonnées, ne charger que id, name et price réduit drastiquement la quantité de données transférées depuis la base.
// SELECT PARTIAL restauré dans Doctrine ORM 3.3.0 (14 octobre 2024)
// Syntaxe DQL : PARTIAL alias.{champ1, champ2, ...}
$dql = 'SELECT PARTIAL p.{id, name, price}
FROM App\Entity\Product p
WHERE p.active = true
ORDER BY p.name ASC';
$products = $entityManager
->createQuery($dql)
->getResult();
// $products contient des instances de Product avec seulement id, name et price hydratés.
// Accéder à $product->getDescription() retournera null — à documenter clairement
// dans le nom de la méthode du repository pour éviter les surprises.
foreach ($products as $product) {
echo $product->getName() . ' : ' . $product->getPrice() . '€';
}DTOs avec la syntaxe NEW : projection directe sans ResultSetMapping
La version 3.3.0 améliore également le support des DTOs imbriqués via la syntaxe NEW en DQL. Tu peux projeter directement dans un objet DTO, en composant des valeurs issues de plusieurs entités jointes — sans avoir à écrire un ResultSetMapping manuel. Le résultat est un tableau d'instances de ton DTO : sans proxy Doctrine, sans UnitOfWork, typé statiquement. Parfait pour les couches de lecture dans une architecture CQRS ou pour des endpoints qui n'ont jamais besoin de modifier les données.
// src/DTO/OrderSummary.php
// PHP 8.3 : readonly class disponible depuis 8.2, ici avec propriétés nommées
final readonly class OrderSummary
{
public function __construct(
public readonly int $orderId,
public readonly float $total,
public readonly string $customerEmail,
) {}
}
// Repository : projection DQL directe dans le DTO
$dql = '
SELECT NEW App\DTO\OrderSummary(o.id, o.total, c.email)
FROM App\Entity\Order o
INNER JOIN o.customer c
WHERE o.status = :status
ORDER BY o.createdAt DESC
';
/** @var OrderSummary[] $summaries */
$summaries = $entityManager
->createQuery($dql)
->setParameter('status', 'shipped')
->getResult();
// Chaque élément est une instance d'OrderSummary : typé, immutable, sans overhead Doctrine.Mesurer avec le Symfony Profiler : ne valide jamais à l'aveugle
Aucune optimisation ne vaut sans mesure avant et après. Le Symfony Profiler intègre un onglet Doctrine qui liste toutes les requêtes SQL exécutées lors d'une requête HTTP : nombre total, temps cumulé, et surtout les requêtes similaires regroupées — signature classique d'un N+1. La Web Debug Toolbar affiche le compteur de requêtes directement en bas de page en développement. Active le backtrace de profiling pour identifier exactement la ligne de code responsable de chaque requête.
# config/packages/dev/doctrine.yaml
doctrine:
dbal:
logging: true
profiling: true
profiling_collect_backtrace: true # Stack trace PHP par requête dans le ProfilerAvec profiling_collect_backtrace: true, chaque requête SQL dans le Profiler (/_profiler) affiche la stack trace PHP qui l'a déclenchée. Quand tu vois 200 occurrences de SELECT * FROM customers WHERE id = ? avec des valeurs différentes, la trace te pointe exactement la ligne du foreach responsable — plus aucun doute sur l'origine du problème. En staging ou sous charge, complète avec Blackfire pour un profiling réaliste qui révèle les N+1 que les petits jeux de données de développement masquent systématiquement.
- Règle de base : si le nombre de requêtes SQL croît linéairement avec le nombre de résultats affichés, tu as un N+1
- Dans le Profiler, onglet « Doctrine » : cherche les groupes de requêtes identiques avec WHERE id = ? — signature du lazy loading en boucle
- La Web Debug Toolbar affiche le compteur de requêtes en bas de page ; fixe un budget (ex. : < 10 requêtes par page) et fais-en une règle de code review
- Pour valider en dehors du navigateur (commandes Symfony, workers), utilise le EventDispatcher ou le logger Monolog configuré sur le canal doctrine
Quelle stratégie pour quel contexte
- Tu modifies les entités après lecture → fetch join DQL (objets complets, UnitOfWork actif, flush possible)
- Tu lis pour afficher ou sérialiser → HYDRATE_ARRAY ou DTO avec NEW (zéro overhead objet)
- Tu as besoin d'un objet mais pas de toutes ses colonnes → SELECT PARTIAL (ORM 3.3.0+ requis)
- La relation est optionnelle et rarement accédée → conserve LAZY et ne la charge pas
- La relation est toujours accédée dans ce use case → fetch join systématique dans le repository, documenté dans le nom de méthode (findWithCustomer, findWithItems…)
- Tu as besoin d'agréger des données issues de plusieurs entités sans modifier → DTO NEW, le plus propre architecturalement
La discipline ici, c'est de traiter chaque méthode de repository comme un contrat explicite : documente quelles relations sont jointes dans le nom de la méthode, évite de te reposer sur le lazy loading implicite dans les couches de présentation. Le N+1 n'est pas un bug Doctrine — c'est la conséquence prévisible d'un design qui délègue les décisions de chargement au runtime. Nommer les méthodes, mesurer avec le Profiler, choisir la bonne stratégie par contexte : c'est tout ce qu'il faut pour que tes 500 commandes restent une seule requête SQL.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.