Le débat a l'âge des ORM eux-mêmes. D'un côté, des développeurs qui ont livré des applications entières sans écrire une ligne de SQL. De l'autre, des développeurs qui passent leurs journées à défaire ce que la couche de mapping a généré. Les deux camps ont des projets réels à montrer.
Dans une même application, les deux situations coexistent. Enregistrer une commande tient en trois lignes et fonctionne sans surprise pendant des années. Sortir le chiffre d'affaires par mois et par catégorie sur douze mois glissants demande une requête que le langage de l'ORM exprime mal, quand il l'exprime.
La question est posée au niveau du projet alors qu'elle se décide au niveau de la requête. Choisir un camp pour toute une application revient à appliquer une réponse unique à deux besoins qui n'ont pas grand-chose en commun : persister des objets métier, et lire des chiffres agrégés.
Voici un critère utilisable en quelques secondes devant n'importe quelle requête, le même besoin écrit des deux manières, les erreurs qui reviennent le plus souvent des deux côtés, et le lien direct avec les index de ta base.
Le moment où la question se pose
Prends une boutique en ligne, trois tables : client, commande, ligne_commande. Deux tickets arrivent dans le même sprint.
Le premier ticket demande de valider un panier. Tu crées un objet, tu lui ajoutes des lignes, tu enregistres. L'ORM gère les identifiants, les clés étrangères, l'ordre des insertions et la transaction. Ce ticket se termine en une heure et le code reste lisible six mois plus tard.
Le second ticket demande un tableau de bord : total facturé par mois, panier moyen, part des dix premiers clients. La requête doit grouper par période, calculer des pourcentages sur des totaux, classer, et ne renvoyer qu'une trentaine de lignes issues de centaines de milliers d'enregistrements.
C'est là que le langage de l'ORM montre ses limites. DQL, côté Doctrine, est un sous-ensemble de SQL centré sur les entités : il ne connaît ni les fonctions de fenêtrage, ni les expressions de table communes, ni les jointures latérales. Contourner produit soit une requête illisible, soit un traitement en PHP sur un tableau de quarante mille objets chargés en mémoire pour en sortir douze chiffres.
Le symptôme est reconnaissable : la page de statistiques est la plus lente de l'application, et personne ne veut y toucher.
Ce que fait un ORM, et ce que tu paies pour
Un ORM rend trois services distincts, qu'on a tendance à confondre.
- Il transforme des lignes de table en objets de ton langage, et inversement. C'est le mapping.
- Il garde en mémoire l'état des objets qu'il t'a donnés, compare cet état au moment de l'enregistrement, et n'envoie que les modifications. C'est le suivi des changements.
- Il génère du SQL à partir d'une API objet. C'est le service le plus visible, et le moins déterminant.
Les deux premiers services sont ce qui rend un chemin d'écriture court et sûr. Quand tu modifies un objet et que tu valides, l'ORM calcule les requêtes nécessaires, respecte l'ordre des dépendances et englobe le tout dans une transaction. Reproduire ça à la main sur une hiérarchie d'objets prend un temps considérable et se casse au premier changement de schéma.
Ces deux mêmes services sont un coût pur sur une requête de lecture qui ne sera jamais réenregistrée. Hydrater quarante mille entités, les indexer dans la mémoire de session et calculer leur état de référence, pour afficher un tableau de douze lignes, revient à payer un service dont tu n'utilises rien.
Ce découpage donne un rapport de forces stable : le chemin d'écriture passe par l'ORM, les rapports et les exports passent par SQL, et une bonne partie des lectures se situe entre les deux, avec des requêtes qui renvoient des objets légers plutôt que des entités complètes. Cette répartition sort mécaniquement du critère, dès lors qu'on l'applique à chaque requête au lieu de trancher une fois pour tout le projet.
Le même besoin écrit des deux façons
Les exemples qui suivent visent Symfony 7.4 LTS, Doctrine ORM 3.7, PHP 8.4 et PostgreSQL 18. Les conventions d'une autre pile diffèrent, la logique de décision non.
Écriture : l'ORM sans discussion
// Symfony 7.4 LTS, Doctrine ORM 3.7, PHP 8.4, PostgreSQL 18
$commande = new Commande($client);
$commande->ajouterLigne($produit, quantite: 2);
$commande->ajouterLigne($autreProduit, quantite: 1);
$em->persist($commande);
$em->flush();
Quatre insertions, une transaction, les clés étrangères remplies dans le bon ordre. Écrire ça en SQL demanderait de récupérer l'identifiant généré, de le propager aux lignes et de gérer l'échec partiel. Personne ne gagne de temps à le faire à la main.
Liste : l'ORM, à condition de joindre
final class CommandeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Commande::class);
}
/** @return Commande[] */
public function findDernieresAvecClient(int $limite = 20): array
{
return $this->createQueryBuilder('c')
->addSelect('cl')
->join('c.client', 'cl')
->orderBy('c.creeLe', 'DESC')
->setMaxResults($limite)
->getQuery()
->getResult();
}
}
La ligne addSelect('cl') fait toute la différence. Sans elle, la jointure filtre mais ne charge rien, et chaque appel à $commande->getClient()->getNom() dans le template déclenche une requête supplémentaire. Vingt commandes affichées, vingt et une requêtes envoyées : c'est le problème dit N+1, la panne de performance la plus fréquente sur une application qui utilise un ORM.
Agrégat : SQL, sans détour
/** @return list<array{mois: string, total: string}> */
public function chiffreAffairesParMois(int $annee): array
{
$sql = <<<'SQL'
SELECT date_trunc('month', c.cree_le) AS mois,
SUM(l.quantite * l.prix_unitaire) AS total
FROM commande c
JOIN ligne_commande l ON l.commande_id = c.id
WHERE c.statut = 'payee'
AND c.cree_le >= :debut
AND c.cree_le < :fin
GROUP BY mois
ORDER BY mois
SQL;
return $this->getEntityManager()
->getConnection()
->executeQuery($sql, [
'debut' => sprintf('%d-01-01', $annee),
'fin' => sprintf('%d-01-01', $annee + 1),
])
->fetchAllAssociative();
}
Douze lignes remontent de la base, aucune entité n'est construite, et la requête reste lisible par quelqu'un qui connaît SQL sans connaître ton framework. Le retour est un tableau associatif, ce qui convient à un graphique ou à un export. Passer par la connexion de Doctrine plutôt que par une connexion ouverte à part garde la transaction, la configuration et le profilage en place.
Entre les deux : la requête qui renvoie un objet léger
/** @return ResumeCommande[] */
public function resumesDepuis(\DateTimeImmutable $debut): array
{
return $this->getEntityManager()
->createQuery(
'SELECT NEW App\Dto\ResumeCommande(c.id, c.creeLe, SUM(l.quantite))
FROM App\Entity\Commande c
JOIN c.lignes l
WHERE c.creeLe >= :debut
GROUP BY c.id, c.creeLe'
)
->setParameter('debut', $debut)
->getResult();
}
L'opérateur NEW demande à Doctrine de remplir un objet de transfert au lieu d'une entité. Tu gardes le typage et l'autocomplétion, tu perds le suivi des changements dont tu n'as pas besoin ici, et le mapping des noms de colonnes reste géré par l'ORM. C'est la sortie de secours à essayer avant de dégainer le SQL natif sur une lecture qui reste dans le champ de DQL.
Ces quatre écritures cohabitent dans la même classe de dépôt, sur le même projet, sans que cela pose de problème d'architecture. C'est le fonctionnement normal d'une application qui a dépassé le stade de la démonstration, et c'est aussi ce qu'on met en place dans la formation Symfony 7, où Doctrine occupe une bonne part des quatre jours.
Les pièges des deux côtés
Attendre de l'ORM qu'il pose les index
Un ORM crée les tables, les colonnes, les clés primaires et les clés étrangères. Il ne devine pas les colonnes sur lesquelles ton application filtre et trie. Une requête générée proprement reste lente si la base doit balayer la table entière pour y répondre, et cette décision t'appartient, déclaration d'index à l'appui. Le mécanisme et la méthode de diagnostic sont détaillés dans l'article sur les index et les requêtes lentes, avec la commande EXPLAIN comme point de départ.
Concaténer une valeur dans du SQL natif
// Avant : la valeur entre dans le texte de la requête
$sql = "SELECT * FROM utilisateurs WHERE email = '" . $email . "'";
// Après : la valeur est envoyée séparément, comme paramètre
$sql = 'SELECT * FROM utilisateurs WHERE email = :email';
$connexion->executeQuery($sql, ['email' => $email])->fetchAssociative();
L'ORM protège de l'injection SQL par défaut, et reprendre la main déplace cette responsabilité sur toi. La règle tient en une phrase : aucune donnée venue de l'extérieur ne rejoint le texte de la requête, elle passe toujours par un paramètre lié. L'article qui démontre l'injection SQL en code exécuté montre ce que donne la version fautive face à une valeur bien choisie.
Modifier des milliers de lignes en boucle
// Avant : chargement, calcul de changements et mise à jour ligne par ligne
foreach ($commandes as $commande) {
$commande->setStatut('archivee');
}
$em->flush();
// Après : une seule requête envoyée à la base
$em->createQuery(
'UPDATE App\Entity\Commande c
SET c.statut = :statut
WHERE c.creeLe < :limite'
)
->setParameter('statut', 'archivee')
->setParameter('limite', $limite)
->execute();
La contrepartie est à connaître : une mise à jour groupée en DQL parle directement à la base et ignore les objets déjà chargés en mémoire. Si tu réutilises une entité chargée avant cette requête, elle porte encore l'ancienne valeur. Sur un traitement par lots exécuté en tâche de fond, ça ne pose aucun problème ; au milieu d'un contrôleur, ça produit des bugs difficiles à reproduire.
Semer du SQL natif que les migrations ne suivent plus
Renomme une propriété d'entité : l'ORM adapte ses requêtes et la migration renomme la colonne. Les chaînes SQL éparpillées dans le code, elles, continuent de mentionner l'ancien nom, et le déploiement passe sans erreur jusqu'à ce que quelqu'un ouvre la page concernée. Regrouper le SQL natif dans les classes de dépôt, plutôt que dans les contrôleurs ou les services, limite dégâts et recherche.
Mesurer sur une base de développement vide
Avec deux cents lignes en base, tout est rapide, y compris ce qui ne le restera pas. Le profileur de Symfony affiche le nombre de requêtes exécutées par page : au-delà d'une dizaine sur un affichage simple, un N+1 se cache derrière. Charger un jeu de données réaliste en local, ou au minimum surveiller ce compteur, coûte moins cher qu'un correctif d'urgence trois mois après la mise en ligne.
Ce que l'état des versions change
Au moment où ces lignes sont écrites, Doctrine ORM 3.7.0 est publiée depuis le 7 septembre 2026, et la branche 4.0 est en développement. La branche 2, elle, est toujours largement en place : dans un billet du 8 octobre 2025, l'équipe Doctrine explique repousser sa fin de vie à février 2027 au plus tôt, en constatant que l'adoption de l'ORM 3 atteint 25 à 30 % de celle de l'ORM 2 d'après les statistiques d'installation de Packagist. Beaucoup de projets français tournent donc encore sur une génération d'ORM que ses auteurs cherchent à faire migrer.
La direction prise par le projet joue directement sur l'arbitrage de cet article. La version 3.3 a rétabli l'hydratation partielle qui avait été retirée, et permis de construire des objets de transfert imbriqués avec l'opérateur NEW. La version 3.4 s'appuie sur les objets paresseux natifs de PHP 8.4, et l'ORM 4 est annoncé comme reposant entièrement dessus. Chaque étape rend les lectures partielles moins coûteuses côté ORM, ce qui déplace la frontière sans la supprimer : aucune de ces versions n'apporte les fonctions de fenêtrage à DQL.
Le raisonnement se transpose tel quel dans les autres écosystèmes. Côté Java, Spring Data JPA et Hibernate présentent exactement la même dualité, avec les mêmes N+1 et la même échappatoire vers la requête native, un point abordé dans la roadmap développeur Spring Boot et travaillé en pratique dans la formation Spring Boot. Le choix du moteur relationnel pèse aussi : les capacités SQL disponibles varient d'un système à l'autre, sujet traité dans le guide des technologies backend.
Reste la compétence qui décide de tout le reste. Un développeur backend qui lit un plan d'exécution et écrit une jointure correcte utilise mieux son ORM que celui qui l'utilise pour éviter SQL. C'est l'ordre d'apprentissage retenu dans les formations backend : la base d'abord, la couche de mapping ensuite, avec quatre jours consacrés au SQL et aux bases de données relationnelles pour ceux qui doivent solidifier ce socle.
Si tu dois retenir une seule chose : ouvre le profileur sur les trois pages les plus lentes de ton application, regarde le nombre de requêtes, et applique le critère à chacune d'elles. Le résultat surprend souvent.