Het Laravel N+1 query-probleem is een verraderlijk prestatieprobleem waar vrijwel elk Eloquent-project vroeg of laat tegenaan loopt. De code ziet er schoon uit, de tests slagen, de pagina laadt snel op je eigen machine — en dan stapelen er een paar duizend rijen op in de database en kruipt diezelfde pagina opeens. De oorzaak is meestal één ding: zonder het te beseffen tientallen of zelfs honderden extra queries afvuren binnen een lus. In dit artikel leg ik stap voor stap uit waarom het gebeurt, hoe je de verborgen queries opspoort met de query log, en hoe je het definitief oplost met eager loading.
Wat is het N+1-probleem precies?
De naam zegt eigenlijk alles. Eén ouderquery (1) draait en geeft N records terug; vervolgens draait er voor elk van die N records nog een query (N) om de gerelateerde gegevens op te halen. Dat zijn 1 + N queries in totaal. Als je 20 berichten toont en bij elk de auteur, gaat er 1 query uit voor de berichten en 20 voor de auteurs: 21 queries.
Kijk naar het klassieke voorbeeld. In een bloglijst willen we bij elk bericht de naam van de auteur tonen:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
De eerste regel levert één enkele select * from posts query op. Maar de uitdrukking $post->author in de lus triggert voor elk bericht een aparte select * from users where id = ? query. Eloquent laadt de relatie via lazy loading — dus op het moment dat ze voor het eerst wordt benaderd. Zo betekenen 100 berichten 101 queries, en dat zou je nooit merken door alleen naar de uitvoer te kijken.
De verborgen queries opsporen met de query log
De eerste stap om het probleem op te lossen is het zichtbaar maken. De DB-facade van Laravel kan elke query die draait vastleggen. Je kunt dit tijdelijk inschakelen binnen een route of controller:
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
$posts = Post::all();
foreach ($posts as $post) {
$post->author->name;
}
dd(DB::getQueryLog());
Zie je 101 regels in de uitvoer, dan is de dader betrapt. Dezelfde SQL die keer op keer terugkeert met where id = ? is de duidelijkste handtekening van N+1.
Een duurzamere aanpak is om elke query te loggen in een service provider:
DB::listen(function ($query) {
logger()->info($query->sql, $query->bindings);
});
Plaats dit in de boot-methode van AppServiceProvider en houd storage/logs/laravel.log in de gaten om precies te zien hoeveel queries één request afvuurt. In ontwikkeling tonen tools als Laravel Debugbar of Telescope dit aantal ook automatisch onderaan elke pagina; in plaats van met het oog herhaalde queries te scannen, zie je gewoon de teller oplopen.
De oplossing: eager loading met with()
De oplossing is verrassend eenvoudig. In plaats van de relatie rij voor rij in de lus te laden, vertel je Eloquent vooraf om "deze relatie ook op te halen" terwijl de ouderquery draait. Dat doe je met with():
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name;
}
Nu draait Eloquent slechts twee queries: één voor de berichten en één die alle auteurs in één keer ophaalt met select * from users where id in (1, 2, 3, ...). Twee queries in plaats van 101 voor 100 berichten. Zo verandert N+1 in 1 + 1.
Je kunt ook meerdere relaties en geneste relaties tegelijk laden:
$posts = Post::with(['author', 'comments.user', 'tags'])->get();
Hier laadt de punt-syntax comments.user ook de gebruiker van elke reactie; zo ontstaat er bij het doorlopen van reacties geen nieuwe N+1. Wil je de query verlichten door alleen bepaalde kolommen te selecteren:
$posts = Post::with('author:id,name')->get();
Let op: in de kolomselectie moet je altijd de foreign key van de relatie opnemen — hier id — anders kan Eloquent de records niet koppelen en komt de relatie als null terug.
Andere handige technieken
- loadMissing(): heb je de modellen al en weet je niet zeker of de relatie geladen is, dan vult dit de ontbrekende aan zonder overbodige queries:
$posts->loadMissing('author'). - withCount(): toon je alleen het aantal gerelateerde records, haal dan het aantal in één query op in plaats van alle rijen:
Post::withCount('comments')->get(). Het resultaat komt binnen als$post->comments_count. - Automatische bescherming: Laravel laat je lazy loading volledig verbieden. Voeg
Model::preventLazyLoading(! app()->isProduction())toe inAppServiceProvideren het benaderen van een niet eager-geladen relatie gooit in ontwikkeling een exception. Zo vang je N+1 al terwijl je de code schrijft.
Eager loading is krachtig, maar ontwikkel niet de reflex om overal with() op te plakken. Gebruik je een relatie niet echt op de pagina, dan werkt het laden ervan averechts en blaast het het geheugen op. De regel is simpel: elke relatie die in een lus wordt benaderd, moet eager geladen worden; geen enkele relatie die nooit wordt benaderd.
Veelgestelde vragen
Is het N+1-probleem altijd een echt probleem?
Bij kleine datasets valt het niet op; 6 queries voor 5 records hindert niemand. Maar naarmate de data groeit, stijgt het aantal queries lineair en verstikken honderden queries zowel de database als de responstijd. Het ergste is dat het lokaal onzichtbaar is en in productie ontploft — daarom is vroeg meten de juiste aanpak.
Wat is het verschil tussen with() en load()?
with() plant de relatie terwijl de query wordt opgebouwd, voordat de modellen worden opgehaald. load() voegt achteraf een relatie toe aan een collectie die je al hebt. Beide gebruiken dezelfde gebundelde-query-logica; welke je kiest, hangt af van wanneer je de modellen verkreeg.
Hoe maak ik eager loading verplicht tijdens het testen?
Schakel Model::preventLazyLoading() alleen buiten productie in. Zo gooit tijdens testen en ontwikkelen elke relatie die je vergeet eager te laden een exception, terwijl het in productie stilletjes normaal blijft werken.
Is je Laravel-applicatie trager dan verwacht? De dader is vaak verborgen N+1-queries. We kunnen je project samen profilen en het aantal queries omlaag brengen — neem contact met me op.