Optimiser Eloquent : gros volumes, écritures groupées et index (partie 2/2)
Série « Optimiser Eloquent »
- Relations, N+1 et comptages
- Gros volumes, écritures groupées et index (cette partie)
Cache applicatif, queues, OPcache et assets : Optimiser les performances d'une application Laravel.
La première partie a traité de la lecture : eager loading, comptages, colonnes chargées, détection des N+1. Cette seconde partie s'intéresse à ce qui change avec le volume : parcourir des milliers de lignes sans saturer la mémoire, écrire en groupe, paginer, écrire du SQL à la main quand c'est plus clair, et indexer.
Les exemples reprennent les modèles génériques Post, Comment et User. Les chiffres viennent d'un essai sur Laravel 10.50 avec une base SQLite jetable, rejoué sur Laravel 11.57 et 12.69 avec les mêmes résultats. Ils comptent des requêtes et des lignes, pas des millisecondes.
Traiter les gros volumes sans saturer la mémoire
Pour envoyer un email à tous les inscrits d'une newsletter, Subscriber::all() charge tous les modèles en mémoire d'un coup. Sur un serveur à mémoire PHP limitée, c'est le plantage assuré.
// Risqué : tous les modèles sont en mémoire
$users = User::all();
// Par lots de 500 : 500 modèles en mémoire à la fois
User::chunk(500, function ($users) {
foreach ($users as $user) {
// traitement
}
});
// lazy() : même principe, avec une syntaxe d'itération plus simple
User::lazy()->each(function ($user) {
// les modèles arrivent un par un, chargés par lots en interne
});
chunk() exécute des requêtes successives avec LIMIT et OFFSET, en ajoutant lui-même un tri par clé primaire. Voici ce que l'essai a émis pour Comment::chunk(50, ...) :
select * from "comments" order by "comments"."id" asc limit 50 offset 0
select * from "comments" order by "comments"."id" asc limit 50 offset 50
Le décalage est le point faible. Si la boucle modifie la colonne utilisée dans le where, des lignes sont sautées. Test sur 140 commentaires, tous approuvés, que la boucle passe à approved = false :
Comment::where('approved', true)->chunk(50, function ($comments) {
foreach ($comments as $comment) {
$comment->update(['approved' => false]);
}
});
// 90 commentaires traités sur 140 : le deuxième lot repart de l'offset 50
// alors que les 50 premiers ne correspondent plus au filtre
chunkById() règle le problème en paginant sur la clé primaire (where "id" > ?) au lieu d'un décalage. Le même test avec chunkById(50, ...) a traité les 140 commentaires.
lazy() fonctionne en interne par chunks et partage donc la faiblesse du décalage ; lazyById() pagine sur la clé primaire comme chunkById(). Il ne faut pas confondre lazy() avec cursor() : seul cursor() utilise un vrai curseur PDO, avec une seule requête et des modèles hydratés un par un. Avec MySQL, PDO utilise par défaut des requêtes bufferisées : le jeu de résultats complet est alors chargé côté client avant l'itération, même si les modèles Eloquent sont créés au fur et à mesure. L'économie de mémoire est donc partielle, et la connexion reste ouverte pendant toute l'itération.
cursor() ignore l'eager loading
Un piège propre à Eloquent : cursor() ne charge jamais les relations en avance. Le with() placé avant est accepté sans erreur, mais ignoré, et chaque accès à la relation redevient un N+1.
// 21 requêtes pour 20 articles : le with('author') est ignoré
foreach (Post::with('author')->cursor() as $post) {
echo $post->author->name;
}
// 6 requêtes (3 lots de 8 articles, chacun suivi d'une requête pour les auteurs)
Post::with('author')->lazy(8)->each(function ($post) {
echo $post->author->name;
});
chunkById(8, ...), lazy(8) et lazyById(8) ont chacun donné 6 requêtes dans l'essai. Pour un parcours qui lit des relations, chunkById() ou lazyById() sont les choix les plus sûrs. cursor() convient aux lectures qui n'utilisent que les colonnes du modèle.
Pour l'envoi d'emails en masse, il vaut mieux répartir le travail dans des jobs de queue que de tout traiter dans une seule requête HTTP ou une seule commande (voir l'article sur les performances).
Écritures : un INSERT par ligne, c'est 100 allers-retours
Le N+1 existe aussi en écriture. Importer 100 lignes avec create() dans une boucle émet 100 requêtes, et autant d'événements Eloquent :
// 100 requêtes INSERT
foreach ($rows as $row) {
Comment::create($row);
}
// 1 requête : insertion groupée
Comment::insert($rows);
Dans l'essai, la première version a émis 100 requêtes, la seconde une seule. insert() passe par le Query Builder : il ne déclenche pas les événements du modèle (creating, created, observers) et ne renseigne pas created_at et updated_at, à fournir dans chaque tableau, par exemple avec now(). Selon le SGBD, un très gros tableau peut dépasser la limite de paramètres liés : on découpe alors avec array_chunk().
Pour « créer ou mettre à jour » en une requête, upsert() évite un updateOrCreate() en boucle :
DB::table('daily_stats')->upsert(
[
['day' => '2026-01-01', 'views' => 5],
['day' => '2026-01-02', 'views' => 7],
],
['day'], // colonnes qui identifient une ligne
['views'] // colonnes mises à jour si la ligne existe
);
Sauf avec SQL Server, les colonnes du deuxième argument doivent porter un index UNIQUE ou PRIMARY (ici, day). Rappeler upsert() avec ['day' => '2026-01-01', 'views' => 9] a mis à jour la ligne existante au lieu d'en créer une seconde.
Paginer sans compter
paginate() émet deux requêtes : le select de la page et un select count(*) pour calculer le nombre total de pages. Sur une grosse table, le count(*) peut coûter plus cher que la page. Quand l'interface n'affiche que « précédent » et « suivant », simplePaginate() suffit :
// select count(*) as aggregate from "posts"
// select * from "posts" limit 15 offset 0
$posts = Post::paginate(15);
// select * from "posts" limit 16 offset 0 (une ligne de plus pour savoir s'il y a une suite)
$posts = Post::simplePaginate(15);
Requêtes brutes : pas honteuses, parfois plus lisibles
Utiliser DB::select() avec du SQL brut n'est pas « sale » dans Laravel. Pour une requête analytique avec agrégats ou fonctions propres au SGBD, le SQL écrit à la main est parfois plus lisible. Exemple de statistiques pour un tableau de bord :
$stats = DB::select('
SELECT
DATE(created_at) as date,
COUNT(*) as total,
SUM(amount) as revenue
FROM orders
WHERE created_at >= ?
GROUP BY DATE(created_at)
', [now()->subMonth()]);
Le ? est un paramètre lié, donc protégé contre les injections SQL. Le SQL brut n'est pas plus rapide pour autant : le Query Builder envoie le même SQL.
$stats = DB::table('orders')
->selectRaw('DATE(created_at) as date, COUNT(*) as total, SUM(amount) as revenue')
->where('created_at', '>=', now()->subMonth())
->groupByRaw('DATE(created_at)')
->get();
Le choix relève de la lisibilité et de la maintenance, pas de la performance. Une sous-requête liée à la ligne courante s'écrit aussi sans SQL brut, avec addSelect(). Elle remonte par exemple le dernier commentaire de chaque article en une seule requête, sans charger la relation :
$posts = Post::addSelect([
'last_comment' => Comment::select('body')
->whereColumn('post_id', 'posts.id')
->latest('id')
->limit(1),
])->get();
// $post->last_comment
Le SQL obtenu est select "posts".*, (select "body" from "comments" where "post_id" = "posts"."id" order by "id" desc limit 1) as "last_comment" from "posts".
Les index : l'optimisation invisible
Erreur fréquente : des colonnes utilisées dans des WHERE ou des ORDER BY sans index. Sur 100 lignes, rien ne se voit. Sur des dizaines de milliers, la même requête peut devenir nettement plus lente.
// Dans une migration
Schema::table('posts', function (Blueprint $table) {
$table->index('user_id');
$table->index('published_at');
$table->index(['status', 'created_at']); // index composite
});
L'index composite ['status', 'created_at'] sert les requêtes du type ->where('status', 'published')->orderBy('created_at', 'desc'). L'ordre des colonnes compte : d'abord celles testées par égalité (status = 'published'), puis la colonne de tri ou de plage (created_at). Avec cet ordre, MySQL peut filtrer sur status puis lire les lignes déjà triées par created_at. Un index (created_at, status) ne permettrait pas d'exploiter le filtre d'égalité puis le tri de la même façon.
Les clés étrangères méritent une attention particulière : l'eager loading d'une relation hasMany filtre dessus (where "comments"."post_id" in (...)) et withCount() les utilise dans sa sous-requête. Avec MySQL et InnoDB, une contrainte de clé étrangère (foreignId('user_id')->constrained()) crée automatiquement un index si aucun n'existe, comme l'indique la documentation de MySQL : l'index('user_id') ci-dessus est alors redondant. Sans contrainte (foreignId() seul, ou une colonne unsignedBigInteger ordinaire), rien n'est indexé et c'est à la migration de le faire. Tous les SGBD ne se comportent pas pareil : SQLite, par exemple, n'indexe pas les clés étrangères.
Pour vérifier qu'un index sert, on fait précéder la requête d'un EXPLAIN (ou on appelle ->explain() sur le builder) et on lit la colonne key. Le format dépend du SGBD : avec MySQL on lit key et rows, avec SQLite on obtient les instructions de la machine virtuelle, beaucoup moins parlantes. Mesurer avant et après reste la seule façon de savoir si un index a servi.
Cache et Eloquent : deux limites à connaître
Le principe du cache applicatif (Cache::remember, invalidation, tags) est expliqué dans l'article sur les performances. Deux points sont propres à Eloquent :
- Les observers et les événements de modèle ne se déclenchent pas sur les écritures qui passent par le Query Builder ou par une requête de masse (
Category::where(...)->update([...]),insert(),upsert()). Une invalidation de cache branchée sur un observer doit donc être faite à la main dans ces cas. - Un cache de modèles Eloquent stocke des objets PHP sérialisés. Après une modification de la classe du modèle, mieux vaut vider le cache au déploiement.
Ce que le portfolio applique
Quelques-unes de ces pratiques sont en place dans le portfolio, vérifiables dans le dépôt :
preventLazyLoading:Model::preventLazyLoading(! app()->isProduction())est activé dansAppServiceProvider::boot(). En développement et en test, un accès à une relation non chargée lève une exception ; en production il est désactivé pour ne pas casser une page si un cas passe entre les mailles.- Index sur les articles : la table
articlesa des index surstatus,published_atetcategory, complétés par un index composite(status, published_at)qui sert le listing public (filtrer sur le statut, trier par date de publication). cursor()dans le sitemap : la génération du sitemap parcourt les articles publiés aveccursor()plutôt que de tout charger en mémoire, ce qui convient tant que la boucle ne lit que les colonnes du modèle.- Debugbar est installé en dépendance de développement ; Telescope ne l'est pas.
Cela ne fait pas du portfolio un modèle d'optimisation, seulement un point de départ cohérent.
À retenir de cette seconde partie
chunkById()oulazyById()pour parcourir beaucoup de lignes, surtout si la boucle modifie les données ou lit des relations ;cursor()seulement pour des lectures sans relation.insert()etupsert()pour grouper les écritures quand les événements du modèle ne sont pas nécessaires.simplePaginate()quand le nombre total de pages n'est pas affiché.- Un index sur chaque colonne qui apparaît dans un
where()ou unorderBy()fréquent, et unEXPLAINpour le vérifier.
Retour à la partie 1/2 : relations, N+1 et comptages.