ORM : limites, requêtes N+1 et quand préférer le SQL brut

Analyse pragmatique des limites des ORM sur PrestaShop : mesurer et corriger les N+1, stratégies Doctrine (fetch join, projections, batching) et critères pour choisir SQL brut.

Écran d'ordinateur sur lequel apparaît des concepts de ORM, N+1, et SQL brut.

Table des matières :

  1. ORM dans l’écosystème PrestaShop : Doctrine, DBAL… et la réalité du cœur
  2. Limites structurelles d’un ORM : ce qu’il masque (et ce qu’il ne peut pas optimiser)
  3. Requêtes N+1 : le anti‑pattern qui tue les pages produits et le Back‑Office
  4. Mesurer un N+1 sur PrestaShop : profiler, slow queries et corrélation applicative
  5. Corriger un N+1 avec un ORM : fetch join, batch, projections, et limites Doctrine
  6. Quand préférer le SQL brut : critères décisionnels (et comment rester propre)
  7. Checklist pragmatique ORM vs SQL brut pour un module PrestaShop (et pièges fréquents)

ORM dans l’écosystème PrestaShop : Doctrine, DBAL… et la réalité du cœur

Sur PrestaShop, parler d’« ORM » sans préciser la couche est une source de malentendus. Le cœur historique repose majoritairement sur ObjectModel + Db (requêtes SQL construites à la main via DbQuery ou chaînes SQL), ce qui n’est pas un ORM au sens Doctrine/Hibernate : pas d’Unit of Work, pas de change tracking fiable, pas de graphe d’objets géré, pas de stratégies de chargement (lazy/eager) systématiques. C’est une surcouche d’accès aux tables, pratique, mais qui ne vous protège pas des coûts SQL.

Dans la pratique, ObjectModel vous donne surtout :

  • une convention de mapping (table, clé primaire, champs multilang/multishop) ;
  • des hooks de cycle de vie (ex. add(), update(), delete()), utiles mais pas équivalents à un vrai change tracking ;
  • des helpers qui peuvent masquer le vrai coût : new Product($id) et certains getters finissent par déclencher des requêtes au moment où vous ne l’attendez pas.

Depuis l’industrialisation Symfony (PrestaShop 1.7 → 8.x → 9.x), on voit apparaître davantage de Doctrine DBAL (accès bas niveau via Connection, QueryBuilder DBAL) et, selon les contextes (bundles Back-Office, API, CQRS), de Doctrine ORM. En module, vous pouvez exploiter le conteneur Symfony et récupérer une doctrine.dbal.default_connection ou un repository, mais vous êtes alors responsable de la cohérence (transactions, contexte multiboutique, legacy Shop::setContext, etc.). Le fait que le core mélange encore plusieurs paradigmes est précisément ce qui rend les problèmes N+1 difficiles à isoler.

Deux points très concrets propres à PrestaShop qui aggravent les confusions « ORM vs SQL » :

  • Multiboutique / multilanguage : certaines jointures attendues (association shop, tables _lang) ne sont pas automatiques si vous partez sur DBAL/SQL brut. L’inverse est vrai aussi : certains appels legacy ajoutent des contraintes de contexte (boutique, groupe) qui peuvent changer le résultat sans changer votre code.
  • Préfixe de tables (ps_ ou autre) : en SQL brut, si vous hardcodez ps_, vous créez une dette d’installation. Dans le legacy, on passe par _DB_PREFIX_, et côté Doctrine/DBAL, il faut être discipliné (table names explicites, ou génération contrôlée).

Côté versions, les recommandations ci‑dessous visent des stacks réalistes en 2026 : PrestaShop 8.1+ et 9.x, PHP 8.2/8.3, MySQL 8 ou MariaDB équivalent. Si vous intervenez sur une boutique sous charge (pics soldes, indexation, back‑office lourd), activez d’abord une méthodologie de mesure reproductible (voir Audit performance PrestaShop : méthode en 6 étapes reproductibles) avant d’accuser « l’ORM » : un N+1 peut être masqué par OPCache/Redis, puis exploser au premier changement de trafic.

Limites structurelles d’un ORM : ce qu’il masque (et ce qu’il ne peut pas optimiser)

Un ORM résout un problème connu : la conversion entre modèle objet et modèle relationnel. Martin Fowler définit l’Object‑Relational Mapping comme « a technique for converting data between incompatible type systems using object-oriented programming languages » (Martin Fowler, Patterns of Enterprise Application Architecture, Addison‑Wesley, 2002). La promesse est claire : améliorer la productivité et la lisibilité, surtout sur des opérations CRUD.

La contrepartie est tout aussi structurelle : l’ORM travaille avec un graphe d’objets, alors que le SGBD exécute des plans de requêtes optimisés sur des ensembles. Dès que vous sortez du CRUD (reporting, agrégations, segmentation, batch update, opérations multi‑tables), vous forcez l’ORM à simuler des opérations en mémoire (hydration d’entités, itérations, flush par paquets) ou à générer du SQL moins prévisible. Les symptômes classiques en e‑commerce : pages catégories qui chargent les entités produit + attributs + images + stock + prix spécifiques, puis déclenchent des requêtes « annexes » dans des boucles.

Trois limites « mécaniques » reviennent souvent en boutique PrestaShop (surtout en modules) :

  • Hydratation coûteuse : transformer des lignes SQL en objets (entités + proxys + collections) consomme du CPU PHP et de la mémoire. Si vous n’avez besoin que de 5 colonnes pour un export, hydrater 15 relations est du gaspillage.
  • Sur‑sélection : le confort d’un ->getX() pousse à « charger large », voire à faire du SELECT * implicite. Or en prod, les lignes sont parfois larges (descriptions, JSON, champs SEO multilang).
  • Complexité du modèle : prix spécifiques, règles de taxe, stock avancé, déclinaisons, images… Les relations e‑commerce forment vite un graphe « profond ». À partir de là, une stratégie de chargement par défaut (lazy) devient un piège si elle est déclenchée depuis un template, un serializer, ou un hook.

Enfin, un ORM n’est pas un optimizer. Il peut générer une requête correcte mais sous‑optimale (JOIN inutiles, colonnes non nécessaires, conditions qui cassent l’usage d’index, etc.). Et il ne sait pas deviner vos contraintes de perf : index composite, statistiques, cardinalités, partitionnement, hints, ou choix d’algorithmes (hash join vs nested loop) restent côté SGBD. Si vos problèmes sont SQL, traitez‑les SQL (EXPLAIN/ANALYZE, indexation, rewriting), comme détaillé dans Développeur MySQL : optimiser requêtes, schémas et performances en production.

Un bon réflexe (souvent oublié) : quand un écran BO « rame », vérifiez si le coût principal est :

  • DB time (requêtes) ;
  • PHP time (hydratation/normalisation) ;
  • I/O externe (API, filesystem, génération PDF).

Un ORM peut être innocent… ou amplifier un problème ailleurs.

Requêtes N+1 : le anti‑pattern qui tue les pages produits et le Back‑Office

Le problème N+1 apparaît quand votre code exécute 1 requête pour récupérer une liste d’objets (N lignes), puis exécute N requêtes supplémentaires pour charger une relation associée (ou une propriété calculée) pour chaque élément. En e‑commerce, c’est typiquement : liste de commandes → pour chaque commande, charger le client ; ou liste de produits → pour chaque produit, charger les stocks, puis les images, puis les déclinaisons.

En PrestaShop « real world », le N+1 ne vient pas uniquement de Doctrine. Il apparaît aussi très facilement en legacy, par exemple :

  • un Db::getInstance()->getValue(...) appelé dans un foreach sur des produits ;
  • un hook qui enrichit chaque ligne d’une grille BO en allant chercher un champ en base « à la demande » ;
  • un calcul de disponibilité/prix qui déclenche des lectures additionnelles par produit (ou par déclinaison).

Le piège : le N+1 ne se voit pas sur de petits jeux de données. Sur un environnement de dev avec 20 produits, 21 requêtes « passent ». En prod avec 2 000 produits et un back‑office qui liste 300 lignes par page, vous venez de passer de 21 requêtes à 301, voire 901 si vous empilez plusieurs relations. Le coût n’est pas seulement le temps CPU MySQL : c’est aussi la latence réseau, la contention sur le pool de connexions, et la sérialisation PHP.

Pour rendre le coût plus tangible, voici un ordre de grandeur (indicatif) sur une infra où PHP et MySQL ne sont pas sur le même hôte (cas fréquent : DB managée, ou serveur SQL séparé) :

Situation Hypothèse Total approximatif
1 requête liste + N requêtes « détail » N=300, 2 ms SQL moyen + 1 ms overhead côté PHP/driver ~900 ms cumulés
1 requête avec jointures correctes 1 requête à 30–80 ms + hydratation ~50–150 ms
2 requêtes bornées (liste + IN) 2 requêtes à 20–60 ms + assemblage PHP ~60–180 ms

Ces chiffres varient, mais la logique reste la même : le coût cumulé d’un N+1 devient vite votre budget de page.

C’est aussi pour ça que le N+1 est fréquent lors des pics (soldes, Black Friday, campagnes TV/radio en France) : ce n’est pas forcément une requête lente, c’est une multiplication de requêtes moyennes qui finit par saturer la base (CPU, locks, threads, buffer pool).

Mesurer un N+1 sur PrestaShop : profiler, slow queries et corrélation applicative

Sur PrestaShop, commencez par rendre la dette visible. En environnement de test (ou prod contrôlée), activez le profiling SQL et inspectez le nombre de requêtes par page et leur répétition. Le guide PrestaShop debug profiling : activer et analyser performances SQL donne la méthode pour obtenir une trace exploitable côté application (temps, stack, répétitions). Un N+1 typique se repère à des requêtes quasi identiques, différant uniquement par un identifiant.

Une méthode simple (et reproductible) pour confirmer un N+1 :

  1. Reproduire une page représentative (ex. grille commandes BO, page catégorie, page produit avec modules).
  2. Compter le nombre total de requêtes.
  3. Grouper par requêtes similaires (même SQL « fingerprint »).
  4. Observer la corrélation : si un même pattern apparaît N fois, et N correspond au nombre d’éléments affichés, vous avez votre candidat N+1.
  5. Valider en baissant/augmentant artificiellement N (changer la pagination BO, limiter le nombre de produits) : un N+1 évolue quasi linéairement.

Ensuite, corrélez avec le SGBD. Le slow query log reste l’outil le plus pragmatique, surtout quand l’application génère beaucoup de requêtes moyennes plutôt qu’une seule requête très lente. Pour PrestaShop, la procédure est détaillée dans Requêtes MySQL lentes PrestaShop : activer slow query log. Attention : le N+1 peut produire des requêtes « rapides » individuellement (1–3 ms) mais un temps total catastrophique (ex. 400 × 2 ms = 800 ms), d’où l’intérêt d’outils qui agrègent par fingerprint (ex. analyse de logs, Performance Schema, pt‑query‑digest).

Enfin, profilez côté PHP : le N+1 est souvent accompagné d’un coût d’hydratation et de sérialisation (objets Doctrine, normalizers, arrays). Un profil Blackfire ou équivalent permet de voir si vous brûlez du temps en hydrateAll(), en getters, ou en conversions (DateTime, Money, etc.). La démarche de mesure et refactoring est proche de celle décrite dans Dette technique Symfony : profiling Blackfire et refactoring mesurable. Pré‑requis : ne faites pas ce type de profiling à l’aveugle en prod ; cadrez le périmètre et l’impact.

Point d’attention « exploitation » : si votre PHP et votre base sont séparés (ce qui est courant en hébergement managé ou en cluster), le coût d’un N+1 augmente avec la latence inter‑serveurs. C’est une raison supplémentaire de privilégier des requêtes bornées (1–3) plutôt que 300 petites, même si chacune est « rapide » sur le papier.

Corriger un N+1 avec un ORM : fetch join, batch, projections, et limites Doctrine

La correction la plus propre, quand elle existe, est de ramener le graphe nécessaire en une requête (ou en un nombre borné de requêtes) via eager loading / fetch join. En Doctrine ORM, cela passe classiquement par des JOIN + addSelect() sur les associations nécessaires (documentation Doctrine : joins en DQL, incluant le principe de « fetch join » : https://www.doctrine-project.org/projects/doctrine-orm/en/2.16/reference/dql-doctrine-query-language.html#joins).

Concrètement, vous remplacez un accès foreach ($orders as $o) { $o->getCustomer()->… } par une requête qui joint les clients dès le départ.

Exemple (Doctrine ORM) :

// Repository Doctrine ORM
public function findOrdersWithCustomer(int $limit): array
{
    return $this->createQueryBuilder('o')
        ->addSelect('c')
        ->join('o.customer', 'c')
        ->orderBy('o.id', 'DESC')
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();
}

Ce pattern règle beaucoup de N+1, mais pas tous. Sur des relations volumineuses (ex. produits ↔ déclinaisons ↔ stocks ↔ images), un fetch join naïf peut exploser en produit cartésien (duplication de lignes), gonfler la mémoire PHP et faire empirer la situation. Signaux d’alerte :

  • le résultat SQL contient beaucoup plus de lignes que d’objets attendus (duplication) ;
  • le pic mémoire PHP grimpe fortement lors de l’hydratation ;
  • la pagination devient incohérente (LIMIT appliqué avant déduplication côté ORM).

Dans ces cas, deux stratégies sont souvent plus robustes : (1) projections (ne sélectionner que les colonnes utiles, hydrater en tableau) ; (2) batching (requêtes IN sur des IDs, puis reconstruction en PHP). Doctrine supporte les hydratations en array/scalar pour réduire le coût objet.

Mini‑schéma de batching (idée générale) :

  1. Requête A : récupérer la liste paginée (ex. 300 id_product).
  2. Requête B : récupérer les données associées par paquets (WHERE id_product IN (...)).
  3. Assembler côté PHP via un index ($byProductId[$id]).

C’est souvent un excellent compromis en e‑commerce : 2 requêtes stables valent mieux qu’un fetch join gigantesque, surtout si les relations sont optionnelles (images, tags, attributs).

Dernier point : les ORMs gèrent mal les opérations de masse « set‑based ». Un UPDATE sur 50 000 lignes via entités + flush est presque toujours une mauvaise idée (change tracking, triggers, memory). Préférez les requêtes SQL/DBAL directes avec transactions et, si nécessaire, verrouillage explicite. Si vous avez un doute, revenez aux fondamentaux MySQL (EXPLAIN, index) et à une lecture de plan : l’ORM n’est pas une couche d’optimisation.

Quand préférer le SQL brut : critères décisionnels (et comment rester propre)

Le SQL brut devient le meilleur choix dès que vous avez besoin de contrôle : agrégations complexes (GROUP BY, HAVING), fenêtrage (window functions sur MySQL 8), CTE, unions, calculs de marges, segmentation marketing, extraction pour un flux, ou construction d’un index de recherche. Typiquement, l’index de recherche interne de PrestaShop repose sur des tables et pondérations spécifiques ; s’appuyer sur SQL est naturel (voir Index de recherche PrestaShop : fonctionnement, tables SQL et pondérations). Dans ces cas, « forcer » l’ORM revient souvent à écrire du SQL déguisé, plus difficile à relire.

Deuxième critère : la prévisibilité des performances. Sur une boutique sous forte charge, une requête SQL maîtrisée (colonnes minimales, index alignés, LIMIT/OFFSET contrôlés, requête stable) vaut mieux qu’un graphe Doctrine qui varie selon les accès (lazy) et peut se dégrader à la moindre modification de template ou de serializer. Si votre objectif est de garantir un budget temps (p. ex. 200–300 ms côté PHP pour une page back‑office), le SQL brut permet de verrouiller l’I/O et la cardinalité.

Troisième critère : les bulk operations. Exemple réel observé sur des modules de synchronisation : recalculer les disponibilités ou prix spécifiques pour des milliers de déclinaisons. En Doctrine, la tentation est de charger les entités puis d’itérer. En SQL, vous faites un INSERT … ON DUPLICATE KEY UPDATE ou un UPDATE … JOIN dans une transaction, avec un coût constant côté PHP. Pour une démarche rigoureuse (et éviter la régression), appuyez‑vous sur une méthode de benchmark et de suivi comme dans Performance PrestaShop : benchmarks et optimisation PHP‑FPM, OPCache, MySQL.

Implémentation : en module moderne (PrestaShop 8/9), privilégiez Doctrine DBAL (requêtes préparées, typage, transactions) plutôt que concaténer du SQL à la main. Exemple DBAL :

/** @var \Doctrine\DBAL\Connection $cnx */
$cnx = $this->get('doctrine.dbal.default_connection');

$sql = <<<SQL
SELECT o.id_order, o.reference, c.email
FROM ps_orders o
JOIN ps_customer c ON c.id_customer = o.id_customer
WHERE o.date_add >= :since
ORDER BY o.id_order DESC
LIMIT :limit
SQL;

$stmt = $cnx->prepare($sql);
$stmt->bindValue('since', $since->format('Y-m-d H:i:s'));
$stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
$rows = $stmt->executeQuery()->fetchAllAssociative();

Deux remarques de propreté (souvent négligées) quand vous choisissez DBAL/SQL :

  • Transactions explicites dès que vous écrivez sur plusieurs tables : vous évitez les états intermédiaires (et vous réduisez les problèmes « fantômes » lors de pics de trafic).
  • Contexte PrestaShop (shop/lang) : si votre requête lit/écrit des tables dépendantes du shop, vérifiez systématiquement que vous filtrez correctement, sinon vous créerez des incohérences difficiles à diagnostiquer en multi‑boutique.

En legacy, si vous passez par Db::getInstance(), gardez les mêmes exigences : requêtes préparées quand possible, casting strict, pas d’IDs injectés, et surtout aucune interpolation de paramètres provenant d’un contexte HTTP. Pour le volet sécurité PHP (typage, validations, surface d’attaque), les règles restent celles de PHP : bonnes pratiques sécurité, typage et qualité de code.

Checklist pragmatique ORM vs SQL brut pour un module PrestaShop (et pièges fréquents)

Si votre fonctionnalité est principalement CRUD (entité métier simple, relations limitées, écrans BO), un ORM peut rester pertinent : il réduit le boilerplate, standardise les validations, et facilite les tests. Mais fixez des règles d’équipe : pas de lazy access dans les templates, pas de serialization qui déclenche des getters chargés en DB, et un budget de requêtes par page. Sur PrestaShop, ce point est critique parce que des morceaux de legacy peuvent contourner l’ORM et rajouter des requêtes à votre insu.

Checklist de revue de code (rapide, mais efficace) :

  • Sur une liste paginée : combien de requêtes pour afficher 50/100/300 lignes ?
  • Y a‑t‑il un foreach qui appelle un repository / Db::getInstance() / un service qui lit en base ?
  • Les colonnes sélectionnées sont‑elles minimales (pas de gros champs texte si non affichés) ?
  • Les filtres multiboutique/multilang sont‑ils explicites (sinon bug latent + perf aléatoire) ?
  • Les IDs utilisés dans des IN (...) sont‑ils bornés (taille max) et construits proprement ?
  • Les écritures en masse sont‑elles faites en set‑based (SQL) plutôt qu’en « boucle + update » ?

Si vous avez des listes, des exports, des tableaux de bord, ou des traitements planifiés (cron) qui touchent beaucoup de lignes : partez SQL/DBAL par défaut. Vous gagnerez en prédictibilité, et vous pourrez optimiser avec les outils MySQL standard (index, EXPLAIN). Les erreurs les plus coûteuses vues en prod sont presque toujours des « petites boucles » qui déclenchent des requêtes : un N+1 « acceptable » à 50 éléments devient un incident à 5 000.

Enfin, gardez en tête les pièges d’exploitation : (1) activer des options de debug en prod sans cadre ; (2) ignorer la configuration PHP qui amplifie la latence (OPcache, pool PHP‑FPM) ; (3) ne pas surveiller les métriques SQL (temps total, nombre de requêtes, contention). Sur une stack e‑commerce, l’optimisation n’est pas un exercice académique : un N+1 sur le checkout, c’est directement un taux d’abandon qui monte. Pour éviter de bricoler, cadrez vos changements avec une stratégie de mesure et, si nécessaire, un plan de rollback comme dans une migration contrôlée (voir Migration PrestaShop : audit technique et plan incrémental blue/green).


À lire aussi