Laravel

Eloquent et N+1 : pourquoi vos tests ne les voient pas

La garde de Laravel contre le chargement différé ne se déclenche que si la requête renvoie plus d'une ligne. Vos tests en créent une seule : c'est ainsi qu'un N+1 arrive en production sans avoir jamais fait échouer un test.

A Azetria Agence digitale 4 min de lecture
Avec un seul élément la garde dort et la page ne fait qu'une requête ; à partir de deux elle s'arme et révèle les 1 + N requêtes
Avec un seul élément la garde dort et la page ne fait qu'une requête ; à partir de deux elle s'arme et révèle les 1 + N requêtes
En bref
Laravel n'arme sa garde contre le chargement différé que lorsqu'une requête renvoie plus d'un modèle : sur une seule ligne, un accès à une relation non chargée coûte une requête, pas N, et le framework laisse donc passer. Comme la plupart des tests créent un seul enregistrement, ils ne déclenchent jamais l'exception. Créer deux enregistrements plutôt qu'un dans les tests qui affichent une liste suffit à faire apparaître les N+1 avant la mise en ligne.

Un N+1 ne se remarque presque jamais le jour où on l’écrit. Il se remarque le jour où la base grossit : la page qui affichait trois éléments en affiche deux cents, et le temps de réponse est passé de 80 ms à quatre secondes.

Laravel propose une garde contre cela, Model::preventLazyLoading(). On l’active dans le AppServiceProvider, et toute relation consultée sans avoir été chargée lève une exception. C’est excellent. Mais il y a une subtilité que la documentation ne met pas en avant, et qui explique pourquoi des N+1 franchissent quand même une suite de tests verte.

La garde ne s’arme qu’au-delà d’une ligne

Le code du framework est explicite. Quand Eloquent transforme le résultat d’une requête en modèles :

if (count($items) > 1) {
    $model->preventsLazyLoading = Model::preventsLazyLoading();
}

Une requête qui ne renvoie qu’une ligne produit un modèle sur lequel la garde n’est pas posée. On peut y consulter toutes les relations non chargées que l’on veut, rien ne se plaint.

Ce choix se défend parfaitement. Sur un seul modèle, consulter une relation non chargée coûte une requête supplémentaire — pas N. Ce n’est pas un N+1, c’est une requête de plus, et lever une exception pour cela rendrait la garde insupportable sur toutes les pages de détail.

Le problème est dans nos tests, pas dans le framework

Voilà ce qu’on écrit d’habitude pour vérifier qu’une page de liste fonctionne :

public function test_le_catalogue_affiche_les_articles_publies(): void
{
    $publie = Article::factory()->published()->create();
    $brouillon = Article::factory()->create();

    $this->get('/catalogue')
        ->assertOk()
        ->assertSee($publie->titre)
        ->assertDontSee($brouillon->titre);
}

Ce test est correct. Il vérifie ce qu’il annonce. Mais la requête de la page ne renvoie qu’une ligne — le seul article publié — donc la garde n’est jamais armée, et la vue peut consulter $article->auteur sans que rien ne le signale.

En production, avec quarante articles, cela fait quarante-et-une requêtes.

Nous l’avons vécu cette semaine sur notre propre catalogue. Treize tests couvraient la page, tous verts, et une relation n’était pas chargée depuis le début. Elle n’est apparue que le jour où un test a eu besoin de deux éléments publiés au lieu d’un — pour une raison qui n’avait rien à voir avec la performance.

Le correctif tient en une ligne

Dans tout test qui affiche une liste, créez deux enregistrements plutôt qu’un.

$premier = Article::factory()->published()->create();
$second = Article::factory()->published()->create();

C’est tout. À partir de deux, la garde s’arme, et la moindre relation oubliée fait échouer le test avec le nom exact de la relation en cause.

Le second enregistrement n’est pas décoratif : il fait partie de ce que le test vérifie. Écrivez-le en commentaire, sans quoi quelqu’un le supprimera un jour en trouvant qu’un seul suffisait — et il aura l’air d’avoir raison, puisque le test passera encore.

Ce que cela dit des jeux d’essai en général

Le cas dépasse la question des N+1. Un jeu d’essai à un seul élément ne révèle pas grand-chose : il ne montre ni l’ordre de tri, ni la pagination, ni les collisions de références, ni les doublons d’identifiants. La plupart des bugs de liste demandent au moins deux lignes pour exister.

Nous avons pris l’habitude de nous poser la question à l’envers : ce test passerait-il encore si les données étaient dix fois plus nombreuses ? Quand la réponse est non, ce n’est pas la production qui est fragile, c’est le test qui était trop indulgent.

Trois vérifications qui coûtent peu

Comptez les requêtes, ne mesurez pas le temps. Sur une base de développement presque vide, quarante requêtes répondent aussi vite qu’une seule. Le temps ne dira rien ; le nombre, si.

Chargez les relations là où la requête est écrite, pas dans la vue. Une vue ne sait pas combien de fois elle sera rendue. Le with() appartient au contrôleur, à côté du where().

Méfiez-vous des composants. Une carte réutilisable qui consulte $article->categorie->nom est parfaitement innocente vue de l’intérieur. C’est la boucle qui l’entoure, ailleurs, qui la rend coûteuse — et rien dans le fichier du composant ne le laisse deviner.


Un N+1 n’est pas une erreur d’inattention : c’est une erreur qu’un outil devrait attraper. Laravel fournit cet outil, correctement conçu pour ne pas crier au loup. Encore faut-il lui donner de quoi voir — c’est-à-dire deux lignes au lieu d’une.

Questions fréquentes

Il protège l'exécution réelle, pas la vérification. La garde fonctionne parfaitement en production et en développement dès qu'une liste comporte plusieurs éléments ; ce sont les tests qui la contournent sans le vouloir, en ne créant qu'un enregistrement.

Non. Elle lève une exception, et une page qui s'affiche lentement vaut mieux qu'une page qui ne s'affiche pas. On l'active en local et lors des tests, et l'on compte sur eux pour ne rien laisser passer.

En comptant les requêtes plutôt qu'en mesurant le temps. Une page qui exécute quarante requêtes pour afficher vingt éléments a un N+1, même si elle répond en 200 ms sur une base presque vide.

Sources

A

Azetria

Agence digitale

Publications collectives de l'équipe Azetria : notes de veille, retours d'expérience de projet et synthèses méthodologiques.

  • Laravel
  • SaaS
  • IA appliquée
  • SEO

La prestation associée

Pour les entreprises dont le site doit faire plus qu'exister

Création de site web sur mesure avec Laravel

À lire ensuite

La base contient ecommerce-01, l'administration le renomme boutique-01, et le seeder relancé réinsère ecommerce-01 en second — suivi des quatre parades : clé immuable, test de réconciliation, ne pas écraser, décider qui possède la donnée
Laravel 5 min de lecture

Le seeder qui recrée ce que vous avez renommé

`firstOrCreate` rend un seeder rejouable — tant que la clé de recherche ne bouge pas. Le jour où quelqu'un renomme depuis l'administration,...

A Azetria

Votre projet mérite mieux qu'un devis générique

Dites-nous ce que vous voulez obtenir. Nous vous répondons avec une analyse, une fourchette et les questions que personne d'autre ne vous aura posées.

Réponse sous 4 heures ouvrées · Aucun engagement · Vos données restent chez nous