Zum Inhalt springen
Abstrakte Formen aus Glas und dunklem Papier: links viele dünne Fäden zu einzelnen Würfeln, rechts eine einzige orange Verbindung zur Gruppe

Eloquent N+1 systematisch finden und beheben

N+1 ist der häufigste Performance-Killer in Laravel-Apps. Mit preventLazyLoading, Debugbar und gezielten Query-Inspektionen finden Sie die Stellen, an denen es wehtut.

4 min Lesezeit Laravel & PHP

Warum dieser Leitfaden

N+1 ist selten ein Mysterium, sondern ein Reflex

In jeder Laravel-Anwendung, die wir in den letzten zwei Jahren refaktoriert haben, war N+1 unter den Top-3-Ursachen für langsame Listen-Views. Die gute Nachricht: Wenn Sie einmal die richtige Diagnose-Routine etablieren, taucht das Problem in Ihrem Team kaum noch in Production auf. Dieser Artikel zeigt den Workflow, den wir bei jeder neuen Codebase als Erstes einziehen.

Eine gespannte Mausefalle: der orange Bügel schnappt beim ersten nachgeladenen Zugriff zu.

Strict Mode: Lazy Loading komplett verbieten

Der schnellste Weg, jedes versteckte N+1 sichtbar zu machen, ist Model::preventLazyLoading(). Damit wirft Eloquent eine LazyLoadingViolationException, sobald irgendwo im Code eine Relation ohne with() angefragt wird. In lokaler Entwicklung und Staging einschalten, in Production ausschalten oder loggen statt werfen.

app/Providers/AppServiceProvider.php php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::shouldBeStrict(! $this->app->isProduction());

    // Alternativ feiner steuern:
    Model::preventLazyLoading(! $this->app->isProduction());
    Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
    Model::preventAccessingMissingAttributes(! $this->app->isProduction());
}
Ein Mikroskop über einem Objektträger, auf dem dieselbe Abfrage immer wieder auftaucht, die Wiederholungen sind orange.

Debugbar als zweite Sicherheit

Die Laravel Debugbar zeigt Ihnen pro Request die Query-Liste mit Dauer und Stack-Trace. Achten Sie auf die „Queries“-Sektion: Taucht dort dieselbe SELECT-Anweisung mehrfach mit nur unterschiedlicher WHERE-Klausel auf, haben Sie ein klassisches N+1 gefunden. In CI ergänzen wir das durch Telescope-Snapshots auf Staging.

Code-Review benötigt?

Wir lesen mit, messen mit und liefern eine priorisierte Fix-Liste - inklusive Diff-Vorschlägen.

Performance-Audit anfragen

Der Fix

Vorher und nachher: ein konkretes Beispiel

Stellen Sie sich eine Blog-Index-Seite vor, die zu jedem Artikel den Autor und die ersten drei Kommentare anzeigt. Ohne Eager Loading produziert das pro Seite mit 20 Artikeln gerne 41 Queries. Mit korrektem with() landen wir bei 3. Das ist nicht akademisch - das ist der Unterschied zwischen 800 ms und 45 ms Time-to-First-Byte.

Ein Berg kleiner Päckchen: jeder einzelne Artikel kam mit einer eigenen Lieferung, die ursprüngliche Bestellung ist orange.

Vorher: jede Relation ein eigener Query

Klassisches Symptom: ein Controller, der nur ::all() oder ::paginate() macht, und ein Blade-Template, das munter $post->author->name und $post->comments aufruft. Funktional sauber, aber Eloquent feuert pro Iteration einen neuen Query - einmal für den Autor, einmal für die Kommentare.

app/Http/Controllers/PostController.php (vorher) php
public function index(): View
{
    // Ein Query holt die Posts
    $posts = Post::latest()->paginate(20);

    // Im Template: $post->author->name → 1 Query je Post
    //              $post->comments     → 1 Query je Post
    // => 1 + 20 + 20 = 41 Queries
    return view('posts.index', ['posts' => $posts]);
}
Ein oranger Bollerwagen bringt alles in einer einzigen Fahrt.

Nachher: with() lädt vor, withCount zählt vor

with() löst die Relationen in einen zweiten Query mit WHERE IN auf. withCount() ergänzt Aggregat-Spalten, ohne die Relation komplett zu laden. Für Listen-Views ist withCount oft der bessere Trade-off - Sie brauchen selten die vollständigen Kommentar-Objekte, nur die Anzahl.

app/Http/Controllers/PostController.php (nachher) php
public function index(): View
{
    $posts = Post::query()
        ->with(['author:id,name,avatar_url'])
        ->withCount('comments')
        ->latest()
        ->paginate(20);

    // 3 Queries gesamt: posts, authors (WHERE IN), comments-count
    return view('posts.index', ['posts' => $posts]);
}

Regression verhindern

Einmal fixen, dauerhaft verhindern

Ein N+1 zu fixen ist die halbe Miete. Die andere Hälfte: dafür sorgen, dass derselbe Bug nicht in zwei Sprints wieder im Code landet. Zwei Mechanismen, die wir in jedem Projekt einbauen.

Ein Handzähler klickt bei jeder Abfrage weiter, der orange Knopf zählt mit, bis die Obergrenze erreicht ist.

Query-Zähler in Feature-Tests

DB::enableQueryLog() in Tests aktivieren und die Anzahl der ausgeführten Queries gegen eine Obergrenze prüfen. Sobald Sie einen Eager-Load entfernen, schlägt der Test fehl - bevor die Änderung in Main landet.

tests/Feature/PostIndexTest.php php
use Illuminate\Support\Facades\DB;

public function test_index_view_keeps_query_count_under_budget(): void
{
    Post::factory()->count(20)->create();

    DB::enableQueryLog();
    $this->get('/posts')->assertOk();
    $queries = count(DB::getQueryLog());

    // Hartes Limit. Steigt der Wert, fragen wir warum.
    $this->assertLessThanOrEqual(5, $queries);
}
Eine orange Überwachungskamera an der Wand zeichnet jeden Verstoß auf, die Besucher darunter gehen ungestört weiter.

In Production loggen statt werfen

Lazy-Loading-Verstöße sollen Production-Nutzer nicht treffen. Über handleLazyLoadingViolationUsing() schicken Sie Verstöße in Ihr Logging (Sentry, Bugsnag, eigenes), ohne den Request abzubrechen. So haben Sie Sichtbarkeit, ohne Risiko.

Hat das geholfen? Wir machen Audit, Workshop oder Coaching auch live

Pakete direkt im Shop. Unser Standard-Tagessatz liegt bei 1.500 € netto.

Performance-Audit anfragen