Legacy PHP

Eloquent-Performance in gewachsenen Laravel-Apps: N+1 ist nur der Anfang

Wenn eine Laravel-App langsam wird, fällt der Verdacht fast immer auf die Datenbank oder den Server. Meistens liegt es an keinem von beidem, sondern an der Zahl der Abfragen und der Menge geladener Objekte. N+1, unnötiges Hydrieren, teure Accessors und fehlende Indizes, der Reihe nach, mit Vorher-Nachher und einem klaren Vorgehen.

29. September 2026 11 min · 2.012 Wörter

Wenn eine Laravel-App langsam wird, fällt der Verdacht fast reflexhaft auf die Datenbank oder den Server. Meistens liegt es an keinem von beidem. Es liegt an der Zahl der Abfragen, die eine einzige Seite auslöst, und an der Menge an Objekten, die dabei aus der Datenbank in den Speicher gezogen werden. Beides steht im Applikationscode, nicht in der Hardware. Ein größerer Server macht eine Seite, die zweihundert Abfragen absetzt, nicht schnell. Er macht sie geringfügig weniger langsam und die Rechnung teurer.

Gerade nach einem Framework-Upgrade werden solche Bottlenecks sichtbar, weil endlich jemand genau hinschaut. Wie man ein Laravel überhaupt sicher auf einen neueren Stand bringt, habe ich im vorigen Logbuch beschrieben. Hier geht es um die Schicht darunter: warum die Abfragen zu viele werden und was dagegen hilft. Der Reihe nach, vom Häufigsten zum Subtileren.

Eine Sache vorweg, die für alles Folgende gilt. Jede dieser Optimierungen verändert, wie eine Abfrage arbeitet, und im Zweifel auch, was sie zurückgibt. Wer eine Query-Schicht umbaut, ohne Tests, die das Ergebnis vorher und nachher vergleichen, optimiert im Blindflug. Wie man dieses Netz in einer übernommenen App aufspannt, steht im ersten Teil dieser Reihe.

Erst messen, dann aufhören zu raten

Bevor irgendetwas optimiert wird, kommt eine Zahl auf den Tisch: Wie viele Abfragen setzt die betroffene Seite tatsächlich ab? Ohne diese Zahl ist jede Optimierung ein Bauchgefühl, und Bauchgefühl trifft bei Performance selten die richtige Stelle.

Das Werkzeug dafür muss nicht groß sein. Die Laravel Debugbar zeigt pro Request die Anzahl der Abfragen und die Zeit, die sie kosten. Telescope geht tiefer und protokolliert jede einzelne Query mitsamt Aufrufstelle. Für einen ersten Blick reicht oft schon ein DB::listen-Callback, der bei jedem Request mitzählt, wie viele Abfragen durchlaufen. So einen Callback registriert man gezielt in der Entwicklungs- oder Debug-Umgebung, nicht dauerhaft in Produktion, wo das Mitloggen jeder einzelnen Abfrage selbst Last erzeugt. Dasselbe gilt für Debugbar und Telescope: Entwicklungswerkzeuge, keine Dauergäste auf dem Produktivsystem. Egal womit: Erst wird gemessen, dann wird angefasst. Und nach jeder Änderung wird wieder gemessen, sonst optimiert man ins Blaue.

Worauf man dabei schaut, ist nicht nur die nackte Zahl, sondern das Muster. Fünfzig fast gleiche Abfragen, die sich nur in einer ID unterscheiden, sind der Fingerabdruck eines N+1-Problems. Ein einzelner Aufruf, der für sich Sekunden braucht, deutet dagegen auf die Abfrage selbst: oft ein fehlender Index, manchmal ein schlechter Join, eine teure Sortierung oder eine Sperre. Schon die Art der Abfragen sagt einem, in welche der folgenden Ecken man zuerst schauen muss.

N+1, das Brot-und-Butter-Problem

Das mit Abstand häufigste Muster ist das N+1-Problem. Es entsteht, wenn man eine Liste von Datensätzen lädt und dann in der Schleife pro Zeile auf eine Relation zugreift. Für die Liste braucht es eine Abfrage, und für jede einzelne Zeile kommt eine weitere dazu. Bei einer längeren Liste werden aus einer Abfrage schnell hunderte.

// Vorher: eine Query fuer die Liste, plus eine pro Zeile
$orders = Order::all();

foreach ($orders as $order) {
    echo $order->customer->name;   // je Zeile eine weitere Abfrage
}

Die Lösung heißt Eager Loading: Man sagt Eloquent vorher, welche Relation man braucht, und es lädt sie für alle Zeilen in einer einzigen zusätzlichen Abfrage.

// Nachher: eine Query fuer die Liste, eine fuer alle Kunden
$orders = Order::with('customer')->get();

Aus einer wachsenden Zahl von Abfragen werden damit zwei, unabhängig davon, wie lang die Liste ist. Das ist der größte Hebel im ganzen Thema und meistens der erste, den ich ziehe.

Zwei Ergänzungen, die im Alltag viel sparen. Relationen lassen sich verschachtelt vorladen, with('customer.address') holt Kunde und Adresse in je einer Abfrage für die ganze Liste. Und wenn eine Sammlung schon geladen ist, zieht $orders->load('customer') die Relation nachträglich in einer einzigen Abfrage nach, statt sie zeilenweise zu holen.

Am wirksamsten ist aber, N+1 gar nicht erst durchrutschen zu lassen. In der Entwicklungsumgebung lässt sich Eloquent so einstellen, dass verstecktes Nachladen eine Ausnahme wirft, statt still eine Abfrage nachzuschieben:

// in einem ServiceProvider, nur ausserhalb der Produktion
Model::preventLazyLoading(! app()->isProduction());

Damit fliegt jedes versehentliche N+1 schon beim Entwickeln auf, nicht erst, wenn die Liste in Produktion lang genug geworden ist.

N+1 versteckt sich außerdem gern dort, wo der Relationszugriff weit weg von der Abfrage steht: in einem Blade-Partial, das pro Zeile eingebunden wird, oder in einer API-Resource, die eine Relation serialisiert. Der Controller sieht harmlos aus, die Abfragen entstehen erst beim Rendern. Auch deshalb misst man am fertigen Request und nicht am Controller allein. Eager Loading kann man allerdings auch übertreiben, und genau da fangen die nächsten Punkte an.

Nur laden, was gebraucht wird

Eager Loading behebt die Menge der Abfragen, nicht die Menge der Daten. Wer with('customer') schreibt, lädt die kompletten Kunden-Datensätze mit allen Spalten, obwohl in der Liste nur der Name auftaucht. Bei breiten Tabellen ist das viel Ballast für nichts.

Zwei Werkzeuge helfen. Erstens die Spaltenauswahl, sowohl auf der Relation als auch auf dem Hauptmodell. Zweitens die Aggregat-Methoden, die eine Summe oder Anzahl direkt in der Datenbank berechnen, statt dafür ganze Relationen zu laden.

$orders = Order::query()
    ->with('customer:id,name')      // nur die zwei Spalten, die gebraucht werden
    ->withCount('items')            // Anzahl in der DB, nicht durch Nachladen
    ->get(['id', 'customer_id', 'created_at']);

withCount('items') legt ein Feld items_count an, ohne dass ein einziges Positions-Objekt geladen wird. Für Summen gibt es withSum, für Mittelwerte withAvg. Die Regel dahinter ist simpel: Wenn am Ende nur eine Zahl gebraucht wird, soll die Datenbank sie rechnen, nicht die Anwendung durch das Laden und Durchzählen tausender Objekte.

Ein Detail, das dabei gern beißt: Wer auf dem Hauptmodell Spalten einschränkt, muss die Fremdschlüssel mitnehmen, die die Relationen brauchen. Fehlt in get(['id', 'created_at']) die Spalte customer_id, findet das Eager Loading die Kunden nicht mehr, weil ihm der Schlüssel zum Verknüpfen fehlt. Die nötigen Schlüssel gehören also in die Auswahl, auch wenn sie in der Anzeige selbst nicht vorkommen.

Wenn gar kein Model nötig ist

Jedes geladene Model ist ein vollwertiges PHP-Objekt. Eloquent hydriert die Rohdaten aus der Datenbank in Objekte, mit allen Casts, Relationen und Events, die das Model mitbringt. Das ist bequem und für die meisten Fälle genau richtig. Bei reinen Listen- und Report-Ansichten, die tausende Zeilen nur anzeigen und sonst nichts damit tun, kostet dieses Hydrieren aber spürbar Zeit.

Für solche Pfade lohnt der Verzicht auf das Model. toBase() liefert die Ergebnisse als schlanke Objekte ohne den ganzen Eloquent-Überbau, und wo man gar kein Eloquent braucht, tut es der Query Builder direkt.

// nur anzeigen, kein Model-Verhalten noetig
$zeilen = Order::query()
    ->select('id', 'nummer', 'status')
    ->toBase();

Das ist bewusst der Spezialfall, nicht die Standardempfehlung. Für alles, was mit den Daten noch arbeitet, bleibt das Model die richtige Wahl. Aber für die große, reine Anzeige ist der Unterschied zwischen zehntausend hydrierten Objekten und zehntausend schlanken Zeilen messbar.

Große Mengen streamen

Ein eigenes Kapitel sind Abläufe, die Daten nicht anzeigen, sondern verarbeiten: ein Export, ein Nacht-Job, eine einmalige Datenkorrektur. Wer dort Order::all() schreibt und über die Sammlung iteriert, lädt die komplette Tabelle in den Speicher, bevor die erste Zeile bearbeitet ist. Bei gewachsenen Tabellen ist das der Punkt, an dem der Job mit einem Speicherfehler abbricht.

Die Antwort ist, in Häppchen zu arbeiten statt auf einmal. chunk() verarbeitet die Daten in Blöcken fester Größe, lazy() liefert eine Sammlung, die im Hintergrund nachlädt und sich anfühlt wie eine ganz normale Schleife.

Order::query()
    ->where('status', 'offen')
    ->lazy()                 // laedt in Bloecken, nicht alles auf einmal
    ->each(fn ($order) => $order->exportiere());

lazy() liefert dabei weiterhin vollwertige Eloquent-Models, der Gewinn liegt hier allein im begrenzten Speicherverbrauch, nicht im Wegfall der Hydration wie bei toBase(). Wird während der Verarbeitung geschrieben, ist chunkById oder lazyById die sicherere Wahl, weil sie über die ID paginieren und nicht über einen Offset, der sich durch die eigenen Änderungen verschiebt. Die Zahl der Abfragen steigt dabei leicht, aber der Speicher bleibt flach, und der Job läuft durch, statt zu ersticken.

Die versteckte Falle, teure Accessors

Der subtilste der Fälle sitzt nicht in der Abfrage, sondern in der Model-Definition. Ein Accessor, der eine teure Berechnung anstellt, ist für sich genommen kein Problem. Sobald er aber über $appends automatisch an jede Serialisierung angehängt wird, läuft er bei jeder Zeile mit, und wenn er dabei selbst die Datenbank anfasst, ist das nächste N+1-Problem geboren, nur besser versteckt.

class Order extends Model
{
    protected $appends = ['positionen_summe'];

    public function getPositionenSummeAttribute(): float
    {
        // laeuft bei JEDER Serialisierung, pro Zeile eine Abfrage
        return $this->items()->sum('preis');
    }
}

In einer Liste mit tausend Bestellungen setzt allein dieser eine Accessor tausend zusätzliche Abfragen ab, ohne dass es im Controller sichtbar wäre. Der Fix ist, ihn aus $appends zu nehmen und die Summe dort zu berechnen, wo die Liste geladen wird, per withSum('items', 'preis'). Das legt die Summe als Feld items_sum_preis an, unter dem man sie in der Anzeige abgreift. Der Accessor darf bleiben, nur nicht automatisch bei jeder Zeile feuern. Genau diese Stelle findet die Messung von oben, im Controller sieht man sie nicht.

Zuletzt die Indizes

Erst wenn die Zahl der Abfragen stimmt und die Menge der Daten stimmt, lohnt der Blick auf die einzelne Query. Eine Abfrage, die über eine große Tabelle ohne passenden Index läuft, zwingt die Datenbank, jede Zeile anzusehen. Bei kleinen Tabellen fällt das nicht auf, bei gewachsenen schon.

Das Vorgehen ist hier bewusst nüchtern: nicht raten, sondern messen. EXPLAIN vor die tatsächlich langsame Abfrage setzen und schauen, ob die Datenbank einen Index nutzt oder die ganze Tabelle liest. Fehlt ein Index auf einer Spalte, nach der oft gefiltert oder sortiert wird, ist das die Stelle. Eine langsame Abfrage heißt dabei nicht automatisch, dass ein Index fehlt. Ob überhaupt einer fehlt und welcher, sagt der Ausführungsplan, nicht die reine Laufzeit.

Zwei Dinge dabei. Wird nach mehreren Spalten zugleich gefiltert und sortiert, bringt ein zusammengesetzter Index über genau diese Spalten in der passenden Reihenfolge oft mehr als einzelne Indizes nebeneinander. Und Indizes sind nicht gratis: Jeder beschleunigt das Lesen, kostet aber beim Schreiben ein wenig. Deshalb legt man sie gezielt an, dort wo EXPLAIN sie verlangt, nicht vorsorglich auf jede Spalte. Indizes sind mächtig, aber sie sind der letzte Schritt, nicht der erste. Wer mit ihnen anfängt, optimiert einzelne Abfragen, während nebenan noch hunderte überflüssige laufen.

Stolperfallen

Eager Loading von allem. Der Reflex, nach dem ersten N+1-Fund einfach jede Relation vorzuladen. Damit tauscht man zu viele Abfragen gegen zu viel geladene Daten und hat das Problem nur verschoben. Geladen wird, was die Seite wirklich zeigt, nicht was das Model an Relationen hergibt.

Caching als Pflaster über schlechte Abfragen. Einen langsamen Aufruf zu cachen fühlt sich nach Fortschritt an, verschiebt das Problem aber nur bis zum nächsten Cache-Miss, und die Invalidierung wird zur neuen Baustelle. Erst die Abfrage in Ordnung bringen, dann, wenn überhaupt noch nötig, cachen.

Optimieren ohne zu messen. Der teuerste Fehler steht am Anfang und am Ende. Wer ohne Zahl anfängt, optimiert die falsche Stelle, und wer ohne Zahl aufhört, weiß nicht, ob es etwas gebracht hat. Vorher messen, nachher messen, sonst ist es Glaube, kein Ergebnis.


Eine langsame Laravel-App wirkt oft wie ein Fall für mehr Hardware, ist aber meistens ein Fall für gezielte Änderungen an den richtigen Stellen im Code. Wenn bei Ihnen der Reflex gerade „größerer Server" heißt und Sie vorher wissen wollen, ob das Geld an der richtigen Stelle landet: Schildern Sie mir kurz, wo es klemmt, ich melde mich mit einer ersten Einschätzung. Termin buchen.