Aller au contenu principal

ORM ou SQL écrit à la main : le choix se fait requête par requête

Doctrine, Hibernate et leurs équivalents écrivent le SQL à ta place, et la question de reprendre la main revient sur chaque projet. Cet article donne le critère qui tranche requête par requête, avec le même besoin écrit des deux façons en Symfony 7.4 et Doctrine ORM 3.7, les pièges qui coûtent des heures, et ce qu'un ORM ne fera jamais pour toi.

Architecture logicielle ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

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.

Le résultat sera-t-il modifié puis réenregistré ? oui non Entités suivies par l'ORM Construction SQL absente du langage de l'ORM ? oui non SQL natif Requête de lecture vers un objet léger
Deux questions suffisent à orienter la quasi-totalité des requêtes d'une application de gestion.

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.

Questions fréquentes

Faut-il apprendre SQL avant d'utiliser un ORM ?

Oui. Un ORM produit du SQL, et le jour où une requête traîne, c'est ce SQL qu'il faut lire. Savoir écrire un SELECT avec jointures, comprendre les clés étrangères et les index couvre déjà la majorité des situations. Sans ces bases, une lenteur reste inexplicable et les correctifs se font au hasard.

Un ORM est-il forcément plus lent que du SQL écrit à la main ?

Sur une requête simple, la différence se compte en fractions de milliseconde et ne se voit pas. L'écart devient visible sur deux cas : l'hydratation de milliers d'entités inutiles, et le N+1 qui transforme un affichage en dizaines de requêtes. Ces deux cas se corrigent, l'un par une requête qui renvoie des valeurs, l'autre par une jointure explicite.

Mélanger ORM et SQL natif dans un projet, est-ce une mauvaise pratique ?

Non, à condition que le SQL natif reste rangé dans les classes de dépôt, avec des paramètres liés et un nom de méthode explicite. Ce qui pose problème, c'est le SQL disséminé dans les contrôleurs, les services et les commandes, sans que personne sache où chercher au moment d'un changement de schéma.

Comment repérer un problème N+1 ?

Le compteur de requêtes du profileur suffit. Si le nombre de requêtes d'une page augmente quand le nombre d'éléments affichés augmente, la relation est chargée élément par élément. La correction consiste à joindre et à sélectionner explicitement la relation dans la requête de liste.

Que faire quand DQL ne sait pas exprimer ma requête ?

Écris-la en SQL et exécute-la via la connexion de Doctrine, avec des paramètres liés. Tu conserves la transaction en cours, la configuration de connexion et le profilage. Si le résultat doit revenir sous forme d'entités, la requête native associée à un ResultSetMapping le permet, au prix d'une configuration à écrire.

Un débutant doit-il commencer par l'ORM ou par SQL ?

Par SQL, avec une base réelle et quelques milliers de lignes. Quelques jours suffisent pour être à l'aise sur les jointures, les agrégats et les index. L'ORM se prend en main ensuite bien plus vite, parce que chaque méthode se lit alors comme la traduction d'une requête connue.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous