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.