Optimiser Eloquent : relations, N+1 et comptages (partie 1/2)

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
11 min de lecture
650 vues

Écouter l'article

Optimiser Eloquent : relations, N+1 et comptages (partie 1/2)

Série « Optimiser Eloquent »

  1. Relations, N+1 et comptages (cette partie)
  2. Gros volumes, écritures groupées et index

Les réglages généraux (cache Artisan, OPcache, queues, assets) sont dans l'article Optimiser les performances d'une application Laravel.

Prenons une page de listing d'articles : vingt articles avec leur auteur. Rien de spectaculaire. Pourtant, en ouvrant la Debugbar, on peut y trouver 21 requêtes SQL pour afficher 20 cartes : une pour la liste, puis une par auteur. C'est le N+1.

Eloquent est un excellent outil, mais il devient un piège à performance quand on ne voit pas ce qui se passe sous le capot. Cette première partie traite de la lecture : relations, chargement différé, comptages, colonnes chargées et détection des N+1. La partie 2 couvre les gros volumes, les écritures groupées, les requêtes brutes et les index.

Les exemples utilisent des modèles génériques (Post, Comment, User) et des tables au pluriel (posts, comments, users). Les résultats chiffrés (nombre de requêtes, SQL généré) viennent d'un essai sur Laravel 10.50 avec une base SQLite jetable : vingt articles, sept commentaires par article. Les mêmes essais ont été rejoués sur Laravel 11.57 et 12.69 ; une seule différence est apparue, signalée dans la section sur limit(). Ces chiffres ne disent rien du temps d'exécution, qui dépend de la base, du réseau et du volume.

Le N+1 : le bug silencieux que tout le monde finit par rencontrer

Le N+1 survient quand on charge une collection et que chaque itération déclenche une requête supplémentaire pour une relation. Ça fonctionne : pas d'erreur, pas d'avertissement, juste une page qui ralentit à mesure que les données grandissent.

// Avant : 21 requêtes pour 20 articles (1 + 20)
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // 1 requête par article
}

// Après : 2 requêtes au total
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name; // déjà chargé, aucune requête supplémentaire
}

Le with('author') déclenche le chargement anticipé (eager loading) : au lieu de charger chaque auteur au moment de l'accès, Laravel les charge tous en une seule requête. Voici le SQL réellement émis dans le second cas :

select * from "posts"
select * from "users" where "users"."id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)

Dans une vue Blade, $post->author->name ne laisse voir aucune requête, ce qui rend le N+1 aussi insidieux.

Compter les requêtes sans outil externe

Avant de corriger, il faut mesurer. Sans Debugbar, le journal de requêtes suffit pour un essai rapide ou un test automatisé :

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$posts = Post::with('author')->get();

dump(count(DB::getQueryLog())); // 2

Dans un test PHPUnit, la même idée permet de figer le nombre de requêtes d'une page et de faire échouer le test si un N+1 réapparaît.

Eager loading : les cas les plus fréquents

Au-delà du with() de base, quatre situations reviennent régulièrement.

Charger plusieurs relations d'un coup. Sur la page de détail d'un article, on charge l'auteur, les commentaires et les tags en une ligne :

$posts = Post::with(['author', 'comments', 'tags'])->get();

Relations imbriquées. Pour le profil de l'auteur, et pas seulement son nom, la notation pointée suffit :

$posts = Post::with('author.profile')->get();

Filtrer les relations chargées. Pour n'afficher que les commentaires approuvés, on contraint le chargement. Sans ce filtre, on charge tous les commentaires puis on filtre en PHP, ce qui coûte de la mémoire :

$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)
        ->orderBy('created_at', 'desc');
}])->get();

Filtrer les parents et charger la relation en même temps. whereHas() sélectionne les articles qui ont au moins un commentaire correspondant, mais ne charge rien. On l'écrivait donc deux fois, dans whereHas() puis dans with(). withWhereHas() fait les deux avec la même contrainte :

// Les articles ayant au moins un commentaire de plus de 6 likes,
// avec uniquement ces commentaires chargés
$posts = Post::withWhereHas('comments', fn ($query) => $query->where('likes', '>', 6))->get();

Deux requêtes partent : la première avec un where exists (select * from "comments" where "posts"."id" = "comments"."post_id" and "likes" > ?), la seconde pour charger les commentaires filtrés des articles retenus.

Le piège de limit() dans un eager load

Ajouter ->limit(5) dans la closure d'un with() ne produit pas la même chose selon la version de Laravel :

$posts = Post::with(['comments' => fn ($query) => $query->limit(5)])->get();
  • Laravel 10 : 5 commentaires au total. L'eager loading exécute une seule requête pour tous les articles, et le limit s'applique à cette requête entière. Dans l'essai (vingt articles de sept commentaires), 19 articles sur 20 revenaient sans aucun commentaire. La documentation de Laravel 10 précise d'ailleurs que limit et take ne doivent pas être utilisés pour contraindre un eager load.
  • Laravel 11 et plus : 5 commentaires par article. Le même code a renvoyé 100 commentaires pour 20 articles, soit 5 par article, sur Laravel 11.0.0, 11.57 et 12.69.

Pour obtenir N éléments par parent sur Laravel 10, il faut une autre approche : un package comme staudenmeir/eloquent-eager-limit, une requête avec fonction de fenêtrage (ROW_NUMBER() OVER (PARTITION BY ...)), ou un chargement filtré suivi d'une limite côté vue. Quelle que soit la version, mieux vaut vérifier le comportement dans le projet avant de s'appuyer dessus.

Lazy eager loading : quand les données sont déjà là

Parfois les modèles sont récupérés sans leurs relations, puis on s'aperçoit qu'on en a besoin. Plutôt que de refaire la requête :

$posts = Post::all();

// Charger les relations après coup
$posts->load('author', 'comments');

// loadMissing ne recharge pas ce qui est déjà chargé
$posts->loadMissing('author');

loadMissing() est utile quand un composant partiel peut recevoir des modèles avec ou sans leurs relations. Dans l'essai, enchaîner load('author') puis loadMissing('author') sur vingt articles n'a produit que deux requêtes au total : la seconde instruction n'a rien rechargé.

Compter sans charger : withCount et ses variantes

Afficher « 7 commentaires » sous chaque article est la source de N+1 la plus répandue après l'auteur. Trois écritures donnent le même nombre, pas le même coût :

// 21 requêtes : une par article
foreach (Post::all() as $post) {
    $post->comments()->count();
}

// 21 requêtes aussi : chaque $post->comments charge la collection complète
foreach (Post::all() as $post) {
    $post->comments->count();
}

// 1 requête : le comptage est une sous-requête de la requête principale
foreach (Post::withCount('comments')->get() as $post) {
    $post->comments_count;
}

Le SQL de la troisième version :

select "posts".*, (select count(*) from "comments" where "posts"."id" = "comments"."post_id") as "comments_count" from "posts"

withCount() accepte une contrainte et un alias :

$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => fn ($query) => $query->where('approved', true),
])->get();

// $post->comments_count et $post->approved_comments_count

Dans l'essai, un article à sept commentaires dont trois approuvés donnait bien 7 et 3. Sur le même principe, withSum(), withAvg(), withMin(), withMax() et withExists() calculent d'autres agrégats dans la requête principale :

$post = Post::withSum('comments', 'likes')
    ->withMax('comments', 'likes')
    ->withExists('comments')
    ->first();

// $post->comments_sum_likes, $post->comments_max_likes, $post->comments_exists

Si les modèles sont déjà en mémoire, loadCount() joue le rôle de load() : une seule requête supplémentaire pour toute la collection.

Le piège des accesseurs

Un accesseur qui lit une relation est un N+1 en puissance, puisqu'il cache l'accès derrière un nom d'attribut :

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function approvedCount(): Attribute
{
    return Attribute::get(
        fn () => $this->comments->where('approved', true)->count()
    );
}

Dans une boucle sur 20 articles, cet accesseur déclenche 21 requêtes tant que comments n'est pas chargé, et 2 avec with('comments'). Quand seul un chiffre est nécessaire, withCount() avec contrainte reste plus économe, puisqu'il évite de rapatrier les commentaires. Attention aussi aux collisions de noms : si l'alias d'un withCount porte le même nom qu'un accesseur (approved_count dans l'essai), c'est l'accesseur qui l'emporte, et le N+1 revient.

Existe-t-il au moins un enregistrement ?

Pour savoir si quelque chose existe, count() > 0 demande à la base de compter toutes les lignes correspondantes. exists() pose directement la bonne question :

// select count(*) as aggregate from "comments" where "post_id" = ?
Comment::where('post_id', 1)->count() > 0;

// select exists(select * from "comments" where "post_id" = ?) as "exists"
Comment::where('post_id', 1)->exists();

Sur peu de lignes, la différence est invisible. Sur un grand nombre, exists() peut s'arrêter à la première ligne trouvée : c'est le bon réflexe par défaut.

Filtrer dans la base ou dans la collection

$post->comments->where('approved', true) filtre en PHP une collection déjà chargée, alors que $post->comments()->where('approved', true) construit une requête. Les deux sont corrects, dans des contextes différents. Si les commentaires sont chargés pour d'autres raisons, filtrer la collection évite une requête. Sinon, mieux vaut contraindre le chargement pour ne pas rapatrier le reste.

Ne charger que ce qui sert

Récupérer toutes les colonnes avec User::all() alors qu'un menu déroulant n'a besoin que du nom pèse sur la mémoire, surtout avec un champ texte long comme bio.

// Avant : toutes les colonnes de chaque utilisateur
$users = User::all();

// Après : uniquement ce dont la vue a besoin
$users = User::select('id', 'name', 'email')->get();

// Pour un menu déroulant, un tableau id => nom suffit :
// aucun modèle Eloquent n'est hydraté
$options = User::orderBy('name')->pluck('name', 'id');

// Même principe avec les relations
$posts = Post::select('id', 'title', 'user_id')
    ->with('author:id,name')
    ->get();

pluck() renvoie une collection de valeurs brutes, sans instancier un modèle par ligne. C'est le bon outil quand on n'a besoin ni de relations, ni d'accesseurs, ni de méthodes du modèle.

Avec select() sur une relation, la clé étrangère (user_id dans posts) et la clé primaire (id dans users) doivent toujours figurer dans la sélection, sinon Eloquent ne peut pas faire le lien. Dans l'essai, Post::select('id', 'title')->with('author:id,name') donnait null pour chaque auteur, sans erreur ni avertissement, alors que l'ajout de user_id corrigeait le résultat. La requête sur users partait normalement : seul le rattachement échouait.

Faire apparaître les N+1 pendant le développement

Debugbar montre le nombre de requêtes de chaque page (voir la section sur le profilage de l'article sur les performances). Laravel sait aussi transformer le lazy loading en erreur :

// app/Providers/AppServiceProvider.php, dans boot()
use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! app()->isProduction());

Une nuance : l'exception n'est levée que pour les modèles issus d'un chargement multiple. Dans l'essai, Post::all()->first()->author lève une LazyLoadingViolationException, alors que Post::first()->author et Post::find(1)->author passent sans erreur : un seul modèle a été chargé, et une requête de plus n'a rien d'un N+1. Tout accès paresseux n'est donc pas détecté.

Model::shouldBeStrict() va plus loin. Il active preventLazyLoading, mais aussi preventSilentlyDiscardingAttributes (une attribution de masse sur un champ non autorisé lève une MassAssignmentException au lieu d'être ignorée) et preventAccessingMissingAttributes. Ce dernier réglage rattrape l'erreur du select() vue plus haut quand elle touche un attribut du modèle lui-même : lire title sur un modèle chargé avec select('id') lève une MissingAttributeException au lieu de renvoyer null.

Model::shouldBeStrict(! app()->isProduction());

Ces trois méthodes existent en Laravel 10.50, 11.57 et 12.69 avec le même comportement.

À retenir de cette première partie

  • with() dès qu'une relation est lue dans une boucle, withCount() quand seul un nombre est affiché, exists() plutôt que count() > 0.
  • Un limit() dans un eager load ne limite pas par parent sur Laravel 10, mais le fait sur Laravel 11 et plus.
  • select() sur une relation : toujours inclure la clé étrangère.
  • preventLazyLoading en développement pour que les N+1 lèvent une exception.

La suite traite des gros volumes, des écritures groupées, des requêtes brutes et des index : partie 2/2.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.