Éliminer les requêtes N+1 : techniques avancées
Tout le monde connaît with() pour l'eager loading. Mais il existe des techniques plus avancées pour optimiser vos requêtes : eager loading conditionnel, subqueries, withCount, et même l'eager loading automatique de Laravel 12.
Rappel : le problème N+1
Le problème N+1 survient quand on accède à une relation dans une boucle. Pour N éléments, on exécute 1 + N requêtes au lieu de 2.
Le problème : 26 requêtes pour 25 livres
$books = Book::all(); // 1 requête
foreach ($books as $book) {
echo $book->author->name; // 25 requêtes supplémentaires !
}
La solution basique : 2 requêtes
$books = Book::with('author')->get(); // 2 requêtes
foreach ($books as $book) {
echo $book->author->name; // Pas de requête, déjà chargé
}
Pourquoi c'est critique
Sur une page listant 100 commandes avec client et produits, un N+1 peut générer 300+ requêtes. Temps de réponse qui explose, serveur DB surchargé, utilisateurs frustrés.
Eager loading conditionnel
Parfois, vous ne savez pas à l'avance quelles relations charger. Laravel offre plusieurs options.
Charger après coup avec load()
Lazy eager loading
$books = Book::all();
// Charger conditionnellement
if ($showAuthors) {
$books->load('author');
}
if ($showPublisher) {
$books->load('publisher');
}
Charger seulement si pas déjà chargé
loadMissing() évite les doublons
// Ne charge que si la relation n'est pas déjà en mémoire
$book->loadMissing('author');
// Utile dans les services qui reçoivent des modèles
public function formatBook(Book $book): array
{
$book->loadMissing(['author', 'publisher']);
return [
'title' => $book->title,
'author' => $book->author->name,
];
}
Contraintes sur les relations chargées
Filtrer les relations eager-loaded
// Charger seulement les commentaires publiés
$posts = Post::with(['comments' => function ($query) {
$query->where('published', true)
->orderBy('created_at', 'desc');
}])->get();
// Limiter le nombre de relations
$users = User::with(['posts' => fn($q) => $q->latest()->limit(5)])->get();
Attention au limit() sur les relations
limit() dans une relation eager-loaded s'applique à la requête globale, pas par parent. Pour limiter par parent, utilisez les subqueries ou packages comme staudenmeir/eloquent-eager-limit.
withCount : compter sans charger
Souvent, vous n'avez besoin que du nombre d'éléments liés, pas des éléments eux-mêmes. withCount() est la solution optimale.
Compter les relations
// Ajoute un attribut comments_count
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo "{$post->title} : {$post->comments_count} commentaires";
}
withCount avec contraintes
Compter avec filtres
$posts = Post::withCount([
'comments', // Total
'comments as approved_comments_count' => fn($q) =>
$q->where('approved', true), // Approuvés
'comments as pending_comments_count' => fn($q) =>
$q->where('approved', false), // En attente
])->get();
// $post->comments_count
// $post->approved_comments_count
// $post->pending_comments_count
withSum, withAvg, withMin, withMax
Agrégations sur les relations
$orders = Order::withSum('items', 'total')
->withAvg('items', 'quantity')
->withMax('items', 'price')
->get();
// $order->items_sum_total
// $order->items_avg_quantity
// $order->items_max_price
Subqueries : le niveau supérieur
Pour des cas complexes, les subqueries permettent de récupérer des données liées en une seule requête, sans JOIN ni eager loading.
addSelect avec subquery
Récupérer le dernier vol vers chaque destination
use App\Models\Destination;
use App\Models\Flight;
$destinations = Destination::addSelect([
'last_flight' => Flight::select('name')
->whereColumn('destination_id', 'destinations.id')
->orderByDesc('arrived_at')
->limit(1)
])->get();
// 1 seule requête avec subquery corrélée
// $destination->last_flight contient le nom du vol
Subquery dans orderBy
Trier par donnée liée
// Trier les users par date de leur dernier post
$users = User::orderByDesc(
Post::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
)->get();
Subquery avec agrégation
Date du dernier post de chaque user
$users = User::select([
'users.*',
'last_posted_at' => Post::selectRaw('MAX(created_at)')
->whereColumn('user_id', 'users.id')
])->withCasts([
'last_posted_at' => 'datetime'
])->get();
// $user->last_posted_at est un Carbon instance
Quand utiliser les subqueries
withCount/withSum : comptages et agrégations simples
Subqueries : récupérer une valeur spécifique, tri par donnée liée
Eager loading : charger des collections complètes de relations
Relations imbriquées
Pour les relations multi-niveaux (posts → comments → author), utilisez la notation pointée.
Eager loading imbriqué
// Charge posts, leurs comments, et l'auteur de chaque comment
$users = User::with('posts.comments.author')->get();
// Avec contraintes à chaque niveau
$users = User::with([
'posts' => fn($q) => $q->where('published', true),
'posts.comments' => fn($q) => $q->where('approved', true),
'posts.comments.author',
])->get();
Eager loading automatique (Laravel 12)
Laravel 12 introduit une fonctionnalité expérimentale : l'eager loading automatique. Activez-la et Laravel charge automatiquement les relations quand vous y accédez.
app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
Utilisation transparente
// Avec l'auto eager loading, ce code est optimisé automatiquement
$users = User::all();
foreach ($users as $user) {
foreach ($user->posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->content;
}
}
}
// Laravel détecte l'accès et charge en batch
Attention
L'eager loading automatique est puissant mais peut masquer des problèmes de performance. En dev, préférez Model::preventLazyLoading() pour détecter les N+1, puis optimisez avec with() explicite.
Détecter les N+1
Mieux que corriger : prévenir. Plusieurs outils pour détecter les N+1 avant qu'ils n'arrivent en prod.
preventLazyLoading (dev only)
app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Lance une exception si lazy loading en dev
Model::preventLazyLoading(! $this->app->isProduction());
}
Laravel Debugbar
Terminal
composer require barryvdh/laravel-debugbar --dev
La Debugbar affiche toutes les requêtes exécutées. Si vous voyez des dizaines de SELECT * FROM authors WHERE id = ?, vous avez un N+1.
Laravel Query Detector
Terminal
composer require beyondcode/laravel-query-detector --dev
Détecte automatiquement les N+1 et affiche un warning dans la réponse HTTP.
Patterns avancés
Eager loading par défaut sur un modèle
app/Models/Post.php
class Post extends Model
{
// Toujours charger ces relations
protected $with = ['author', 'category'];
}
// Pour désactiver ponctuellement
$posts = Post::without('author')->get();
Scope pour eager loading standard
app/Models/Order.php
use Illuminate\Database\Eloquent\Attributes\Scope;
class Order extends Model
{
#[Scope]
protected function withDetails(Builder $query): void
{
$query->with([
'customer',
'items.product',
'shippingAddress',
'payments',
])->withSum('items', 'total');
}
}
// Utilisation
$orders = Order::withDetails()->paginate();
Morphs et eager loading
Relations polymorphiques
// Pour les morphTo, spécifiez les types à charger
$comments = Comment::with(['commentable' => function ($morphTo) {
$morphTo->morphWith([
Post::class => ['author'],
Video::class => ['channel'],
]);
}])->get();
Récapitulatif
Checklist anti-N+1
- Activez preventLazyLoading en dev pour détecter les problèmes tôt
- Utilisez with() pour charger les relations utilisées dans les boucles
- withCount/withSum pour les comptages et agrégations
- Subqueries pour les valeurs calculées et tris complexes
- loadMissing() dans les services qui reçoivent des modèles
- Scopes pour standardiser l'eager loading courant
- Debugbar pour vérifier le nombre de requêtes