É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

Besoin d'aide sur votre projet ?

Je peux vous accompagner dans le développement ou l'optimisation de votre application.

Me contacter