PHP-Performance: die acht teuersten Fehler im Code
TL;DR — Wenn eine PHP-Anwendung langsam ist, liegt es selten an PHP. Es liegt fast immer an einem von wenigen wiederkehrenden Mustern im eigenen Code. Dieser Artikel zeigt die acht teuersten, jeweils mit Symptom, Codebeispiel, der passenden Messung und der Auflösung.
Alles in diesem Artikel lässt sich ohne neue Werkzeuge, ohne zusätzliche Server und ohne Framework-Wechsel beheben. Es geht um Muster, nicht um Ausstattung.
Vorab. Die gezeigten Umbauten greifen in laufende Anwendungen ein — einige davon, etwa das frühe Schliessen der Session oder ein autoritativer Classmap, können bei falscher Anwendung Daten kosten oder Klassen unauffindbar machen. Probiere jede Änderung zuerst auf einem Testsystem, ändere eine Sache pro Schritt und miss nach. Für den Betrieb deiner Systeme bist du verantwortlich.
Warum „PHP ist langsam" fast immer die falsche Diagnose ist
PHP 8 ist eine schnelle Sprache. Wenn eine Seite drei Sekunden braucht, verbringt der Interpreter davon typischerweise einen Bruchteil mit dem Ausführen deines Codes — den Rest wartet er. Auf die Datenbank. Auf eine HTTP-Antwort. Auf eine Sperre, die ein anderer Request hält.
Das ist die wichtigste Umstellung: Performance-Arbeit ist Wartezeit-Arbeit. Wer die CPU-Zeit einzelner Funktionen optimiert, während daneben 400 Datenbankabfragen laufen, poliert das Lenkrad eines Autos mit angezogener Handbremse.
Die acht Muster unten sind nach ihrer typischen Wirkung sortiert. Die ersten drei erklären erfahrungsgemäß den Großteil der Fälle.
Die acht Muster im Überblick
| # | Muster | Typisches Symptom | Wirkung |
|---|---|---|---|
| 1 | N+1-Abfragen | Seite wird langsamer, je mehr Daten | linear mit der Zeilenzahl |
| 2 | Alles laden, wenig brauchen | hoher Speicherverbrauch, Timeouts | linear mit Spalten und Zeilen |
| 3 | Blockierende Session | parallele Requests warten aufeinander | Serialisierung pro Benutzer |
| 4 | Quadratisches Wachstum | ab einer Datenmenge plötzlich zäh | O(n²) |
| 5 | Nicht gecachte Wiederholung | konstant hohe Grundlast | Faktor je Wiederholung |
| 6 | Ungünstiger Autoloader | jeder Request kostet extra | konstant, aber überall |
| 7 | Regex statt String-Funktion | unklare Absicht, Backtracking-Risiko | gering — bis auf den Sonderfall |
| 8 | Roundtrip-Verkettung | wartet auf fremde Systeme | Summe aller Latenzen |
1. N+1-Abfragen: die Schleife mit der Abfrage darin
Das mit Abstand teuerste Muster, und das am leichtesten zu übersehende — weil der Code sauber aussieht.
Symptom. Die Seite ist mit Testdaten schnell und in der Produktion langsam. Die Ladezeit wächst proportional zur Anzahl der angezeigten Zeilen.
$bestellungen = $db->query('SELECT id, kunde_id FROM bestellung LIMIT 50')->fetchAll();
$stmt = $db->prepare('SELECT name FROM kunde WHERE id = ?');
foreach ($bestellungen as $b) {
$stmt->execute([$b['kunde_id']]); // Eine Abfrage. Pro Durchlauf. Fünfzig Mal.
echo $stmt->fetchColumn();
}
Eine Abfrage für die Liste, dann eine je Zeile: 1 + N. Bei 50 Zeilen sind das 51 Roundtrips. Jeder einzelne ist für sich schnell — die Summe ist es nicht.
Messung. Zähle die Abfragen pro Request, nicht ihre Dauer. Eine Zeile im Datenbank-Wrapper genügt, oder du aktivierst kurzzeitig das Query-Log der Datenbank. Steigt die Zahl mit der Zeilenzahl der Seite, hast du das Muster gefunden. Diese Messung ist wichtiger als jedes Profiling-Werkzeug: sie beantwortet die Frage direkt.
Auflösung. Die benötigten Datensätze in einem Zug holen und im Speicher zuordnen:
$bestellungen = $db->query('SELECT * FROM bestellung LIMIT 50')->fetchAll();
$ids = array_values(array_unique(array_column($bestellungen, 'kunde_id')));
$kunden = [];
if ($ids !== []) {
$platzhalter = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("SELECT id, name FROM kunde WHERE id IN ($platzhalter)");
$stmt->execute($ids);
$kunden = array_column($stmt->fetchAll(), 'name', 'id');
}
foreach ($bestellungen as $b) {
echo $kunden[$b['kunde_id']] ?? '';
}
Zwei Abfragen statt einundfünfzig. Bei einer echten Beziehung tut es auch ein JOIN.
⚠ Der Fix verlagert die Last, er löst sie nicht immer auf. Ab einer gewissen Listenlänge kippt der Planner vom Index- auf einen sequenziellen Scan, und sehr lange Listen kosten schon beim Parsen und Binden. Hinzu kommt eine harte Grenze: PostgreSQL nimmt höchstens 65.535 Bind-Parameter je Statement — darüber braucht es Chunking oder einen temporären Join. Wenn die eine Abfrage jetzt langsam ist, liegt das Problem eine Ebene tiefer; was EXPLAIN dazu sagt, steht in unserem Beitrag zur Index-Optimierung.
2. Alles laden, wenig brauchen
Symptom. Allowed memory size exhausted, oder die Seite hält bis zu einer bestimmten Datenmenge und kippt dann.
// Holt jede Spalte jeder Zeile in den Speicher — für eine Summe.
$zeilen = $db->query('SELECT * FROM position')->fetchAll();
$summe = 0;
foreach ($zeilen as $z) {
$summe += $z['betrag'];
}
Drei Fehler in vier Zeilen: SELECT * holt Spalten, die niemand liest; fetchAll() materialisiert das gesamte Ergebnis; und die Summe berechnet die Anwendung, obwohl die Datenbank das besser kann.
Messung. memory_get_peak_usage(true) an einer Stelle nach dem Laden. Wächst der Wert linear mit der Datenmenge, lädst du zu viel. Ergänzend: die Zeilenzahl der Abfrage gegen die tatsächlich verwendeten Felder halten.
Auflösung. In dieser Reihenfolge:
- Lass es die Datenbank rechnen.
SELECT sum(betrag) FROM positionüberträgt eine Zahl statt einer Million Zeilen. - Nur die Spalten holen, die du liest.
SELECT id, betragstattSELECT *. - Wenn du wirklich alle Zeilen brauchst: nicht zusätzlich materialisieren. Über das Statement iterieren, statt
fetchAll()aufzurufen — dann muss PHP nicht alle Zeilen gleichzeitig als Array halten.
⚠ Das ist noch kein echtes Streaming. pdo_pgsql holt das gesamte Ergebnis über libpq in den Client-Speicher, pdo_mysql puffert standardmässig ebenfalls — die erste Zeile kommt also erst an, wenn alles übertragen ist. Gespart wird die PHP-seitige Kopie, nicht der Treiber-Puffer. Und Vorsicht bei der Messung: memory_get_peak_usage() sieht nur den Zend-Allokator, den Treiber-Puffer nicht. Wirklich zeilenweise geht es erst mit einem serverseitigen Cursor (DECLARE … FETCH) bzw. PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false.
$summe = (float)$db->query('SELECT sum(betrag) FROM position')->fetchColumn();
Der Punkt ist nicht die Mikrooptimierung. Der Punkt ist, dass Datenmengen wachsen und dieser Code irgendwann kippt — verlässlich zum ungünstigsten Zeitpunkt.
3. Die blockierende Session
Das Muster, das am häufigsten falsch diagnostiziert wird — weil es sich wie ein Server-Problem anfühlt.
Symptom. Einzelne Requests sind schnell. Sobald ein Benutzer mehrere parallel auslöst — mehrere Tabs, ein Dashboard mit vier gleichzeitigen AJAX-Aufrufen —, laufen sie nacheinander statt gleichzeitig ab.
Der Grund: PHPs Standard-Session-Handler legt die Session als Datei ab und hält von session_start() bis zum Skriptende eine exklusive Sperre darauf. Ein zweiter Request desselben Benutzers wartet an dieser Sperre, egal wie schnell er selbst wäre.
session_start();
$benutzer = $_SESSION['user'] ?? null;
// … acht Sekunden Bericht bauen. Die Session bleibt die ganze Zeit gesperrt.
$bericht = baueGrossenBericht();
echo json_encode($bericht);
Messung. Öffne dieselbe Seite in zwei Tabs gleichzeitig. Braucht der zweite Aufruf ungefähr die Summe beider Laufzeiten statt der längeren von beiden, sperrt die Session.
Auflösung. Die Sperre so früh wie möglich freigeben:
session_start();
$benutzer = $_SESSION['user'] ?? null;
session_write_close(); // ab hier ist die Session frei
$bericht = baueGrossenBericht();
echo json_encode($bericht);
Für Endpunkte, die nur lesen, geht es seit PHP 7 noch direkter:
session_start(['read_and_close' => true]); // liest und gibt die Sperre sofort frei
$benutzer = $_SESSION['user'] ?? null;
echo json_encode(baueGrossenBericht($benutzer));
⚠ Ein erneutes session_start() nach der Ausgabe funktioniert nicht. Sind die Header einmal raus, scheitert der Aufruf mit einer Warnung — und mit display_errors=Off still. Jedes folgende $_SESSION[…] = … verpufft dann folgenlos. Wer nach dem Schliessen doch schreiben muss, puffert die Ausgabe, öffnet die Session kurz, schreibt und gibt erst danach aus.
⚠ Und die Sperre hat einen Zweck. Sie serialisiert konkurrierende Schreibzugriffe. Gibst du sie früh frei und mehrere parallele Requests schreiben in die Session, lesen beide denselben Stand und der letzte gewinnt — ein Lost Update, das erst unter Last auftritt, also genau dort, wo du gerade optimiert hast. Zustand, um den mehrere Requests konkurrieren, gehört nicht in die Session, sondern in die Datenbank mit atomarem Update.
4. Quadratisches Wachstum in Schleifen
Symptom. Bis zu ein paar hundert Datensätzen unauffällig, danach unbrauchbar. Der Sprung kommt plötzlich.
Der Klassiker ist die verschachtelte Suche:
foreach ($neue as $n) {
if (in_array($n['id'], $vorhandeneIds, true)) { // durchsucht die Liste jedes Mal komplett
continue;
}
$vorhandeneIds[] = $n['id'];
}
in_array() geht die Liste linear durch. In einer Schleife über dieselbe wachsende Liste ergibt das O(n²): bei 1.000 Einträgen etwa eine halbe Million Vergleiche, bei 10.000 rund fünfzig Millionen.
Auflösung. Ein Array mit den Werten als Schlüssel; der Nachschlag ist dann konstant:
$vorhanden = array_flip($vorhandeneIds); // einmalig
foreach ($neue as $n) {
if (isset($vorhanden[$n['id']])) {
continue;
}
$vorhanden[$n['id']] = true;
}
Der zweite häufige Fall ist array_merge() in einer Schleife — jeder Aufruf kopiert das bisherige Ergebnis komplett. Das array_values() in der Auflösung ist kein Zierrat: trägt $haufen String-Schlüssel, etwa aus einer Gruppierung, liest PHP sie ab Version 8.1 als benannte Argumente und bricht mit ArgumentCountError ab.
$alle = [];
foreach ($haufen as $teil) {
$alle = array_merge($alle, $teil); // kopiert bei jedem Durchlauf alles Bisherige
}
// Besser: sammeln und einmal zusammenführen
$alle = array_merge(...array_values($haufen));
⚠ Ein verbreiteter Rat ist hier falsch. Man liest oft, String-Verkettung in Schleifen sei quadratisch und man solle stattdessen in ein Array sammeln und implode() aufrufen. Für reines Anhängen mit $text .= $zeile; stimmt das in PHP nicht — die Engine hängt amortisiert in konstanter Zeit an. Der Rat kostet Lesbarkeit ohne Gegenwert.
Zwei Ausnahmen, die es wirklich gibt — und die den Unterschied ausmachen:
$s .= $x; // linear. 100.000 Durchläufe: rund 0,004 s
$s = $s . $x; // quadratisch. Dieselben 100.000 Durchläufe: rund 0,67 s
Die beiden Zeilen sehen gleich aus. PHP hängt aber nur dann direkt an, wenn der String nirgends sonst gehalten wird. $s = $s . $x baut ihn jedes Mal neu auf. Dasselbe passiert bei .=, wenn im Schleifenrumpf noch eine Kopie des Strings lebt.
⚠ Die Länge des Angehängten entscheidet, wie gross der Abstand ausfällt — deshalb hier die vollständige Messbedingung: 100.000 Durchläufe mit je 10 Zeichen, PHP 8.4, Faktor rund 175. Hängst du einzelne Zeichen an, schrumpft er auf etwa 17; bei 40 Zeichen wächst er auf über 8.000. Wer gegenmisst und eine ganz andere Zahl sieht, hat meist eine andere Anhängsel-Länge gewählt — nicht ein anderes PHP.
Wer solche Laufzeitfragen systematisch angehen will, findet die Grundlagen im Seminar PHP OOP und fortgeschrittene Technologien — Datenstrukturen und ihre Kosten sind dort ein eigener Block.
5. Nicht gecachte Wiederholung
Symptom. Die Grundlast ist konstant hoch, unabhängig von der Datenmenge. Nichts ist auffällig langsam, alles ist ein bisschen zäh.
function steuersatz(string $land): float
{
// Liest bei JEDEM Aufruf dieselbe Datei und parst sie neu.
$tabelle = json_decode(file_get_contents(__DIR__ . '/steuer.json'), true);
return $tabelle[$land] ?? 0.19;
}
Wird die Funktion in einer Schleife über 200 Positionen aufgerufen, liest PHP die Datei 200-mal.
Messung. Der Xdebug-Profiler oder ein anderer Profiler zeigt dir die Aufrufzahl je Funktion. Eine Funktion, die pro Request hunderte Male mit denselben Argumenten läuft, ist ein Kandidat — unabhängig davon, wie schnell sie einzeln ist.
Auflösung — drei Stufen, aufsteigend nach Lebensdauer:
function steuersatz(string $land): float
{
static $tabelle = null; // Stufe 1: pro Request
if ($tabelle === null) {
$roh = file_get_contents(__DIR__ . '/steuer.json');
$tabelle = $roh === false ? [] : (json_decode($roh, true) ?? []);
}
return $tabelle[$land] ?? 0.19;
}
Die Prüfung auf null statt ??= ist Absicht: schlägt das Lesen fehl, bliebe $tabelle sonst dauerhaft null und die Funktion versuchte es bei jedem Aufruf erneut — der Cache wäre still wirkungslos, und die Funktion lieferte stumm den Rückfallwert. Stufe 1 löst den Fall oben vollständig. Stufe 2 ist ein prozessübergreifender Cache (APCu, Redis) für Daten, die sich selten ändern und deren Berechnung teuer ist. Stufe 3 ist ein vorberechnetes Ergebnis in der Datenbank oder in einer Datei — sinnvoll, wenn die Berechnung Minuten dauert und die Aktualität in Stunden gemessen wird.
Die Reihenfolge ist Absicht: Stufe 1 kostet eine Zeile und hat keine Invalidierung. Jede weitere Stufe bringt ein neues Problem mit — die Frage, wann der Cache falsch wird.
6. Der ungünstig konfigurierte Autoloader
Symptom. Jeder Request kostet eine feste Grundzeit, auch die trivialen. Auffällig auf Systemen mit langsamem oder netzgebundenem Dateisystem.
Composers Standard-Autoloader sucht Klassen über PSR-4-Regeln — das bedeutet Dateisystem-Zugriffe pro Klasse. Bei hunderten Klassen je Request summiert sich das.
Auflösung. Für Produktivsysteme eine Klassenkarte erzeugen:
composer dump-autoload --classmap-authoritative --no-dev
Das schreibt eine feste Zuordnung Klasse → Datei und schaltet die Dateisuche ab. --classmap-authoritative bedeutet: Klassen, die nicht in der Karte stehen, werden gar nicht erst gesucht. Das ist gewollt, setzt aber voraus, dass keine Klassen zur Laufzeit erzeugt werden.
⚠ Nur im Deployment ausführen, nicht in der Entwicklung — sonst findet PHP jede neue Klasse erst nach einem erneuten Lauf.
Der zweite Teil dieses Themas ist die PHP-Konfiguration selbst: OPcache, Preloading, realpath_cache. Das steht in unserem Beitrag zur PHP-Konfiguration, weil es eine andere Zielgruppe betrifft — den Betrieb statt die Entwicklung.
7. Regex, wo eine String-Funktion reicht
Symptom. Kein einzelner Aufruf fällt auf; in einer Schleife über zehntausende Zeilen wird es messbar.
if (preg_match('/^ERROR/', $zeile)) { /* … */ }
if (preg_match('/fehler/i', $zeile)) { /* … */ }
Beide starten die Regex-Maschine für etwas, das PHP direkt ausdrückt:
if (str_starts_with($zeile, 'ERROR')) { /* … */ }
if (stripos($zeile, 'fehler') !== false) { /* … */ }
str_contains(), str_starts_with() und str_ends_with() gibt es seit PHP 8.0.
⚠ Und hier die ehrliche Zahl, damit niemand mit einem eigenen Benchmark antwortet: der Geschwindigkeitsgewinn ist klein. Über 300.000 Aufrufe gemessen liegt str_starts_with() rund 10 % vor preg_match('/^ERROR/'). PHP hält kompilierte Muster in einem Cache vor — kompiliert wird einmal, nicht bei jedem Aufruf. Der Grund für die String-Funktion ist also nicht Tempo, sondern dass sie sagt, was gemeint ist. Wer Muster 7 als Performance-Massnahme verkauft bekommt, wurde falsch beraten.
Teuer wird Regex an einer anderen Stelle — und die ist es wirklich:
Wann Regex trotzdem richtig ist: sobald es um ein echtes Muster geht — variable Teile, Alternativen, Rückreferenzen. Dann ist Regex das richtige Werkzeug, und der Fehler wäre, es durch eine Kette von String-Funktionen zu ersetzen.
⚠ Und die beiden Varianten sind nicht deckungsgleich: stripos() kennt nur ASCII-Grossschreibung. Bei Größe oder Übertrag trifft es daneben, wo preg_match('/über/iu', …) mit dem u-Flag korrekt vergleicht. Wer deutschsprachig sucht, braucht entweder das u-Flag oder mb_stripos().
⚠ Und wenn es Regex sein muss, achte auf die Gierigkeit. Ein .* vor dem eigentlichen Ziel lässt die Maschine erst bis zum Zeilenende laufen und dann zurückarbeiten. Bei verschachtelten Quantoren kann daraus katastrophales Backtracking werden — aus Millisekunden werden Sekunden, bei ungünstiger Eingabe Minuten. Ein häufiger Nebenschauplatz ist die Datumsformatierung, wo oft mit Regex zerlegt wird, was die Datenbank oder DateTimeImmutable sauber kann: dazu steht mehr in unserem Beitrag zu SQL-Datumsformaten.
8. Roundtrip-Verkettung
Symptom. Die Anwendung wartet, ohne zu rechnen. Der Profiler zeigt kaum CPU-Zeit, die Seite braucht trotzdem Sekunden.
foreach ($artikel as $a) {
$preis = $api->hole("/preis/{$a['id']}"); // je 120 ms Netzwerk
$lager = $api->hole("/lager/{$a['id']}"); // nochmal 120 ms
// …
}
Bei 20 Artikeln sind das 40 Aufrufe. Die Rechenzeit ist vernachlässigbar, die Wartezeit nicht.
Auflösung — in dieser Reihenfolge:
- Bündeln. Bietet die Gegenstelle einen Sammel-Endpunkt (
/preise?ids=1,2,3), ist das die beste Lösung: aus 40 Aufrufen wird einer. - Parallelisieren. Wenn nicht, laufen die Aufrufe nebenläufig — mit
curl_multi_*oder einem HTTP-Client, der das kann. Aus der Summe der Latenzen wird ihr Maximum. - Cachen. Preise, die sich stündlich ändern, müssen nicht pro Request geholt werden (siehe Muster 5).
Dasselbe Muster tritt bei Datenbanken auf, wenn die Anwendung über mehrere Systeme verteilt ist. Wie man Abfragen über Systemgrenzen hinweg zusammenfasst und wo die Grenzen dieses Ansatzes liegen, behandelt das Seminar Datenbankübergreifendes Performance-Tuning.
Reihenfolge: erst messen, dann ändern
Drei Regeln, die den Unterschied zwischen Optimieren und Herumraten ausmachen:
Miss auf produktionsnahen Daten. Alle acht Muster oben sind auf einem Testdatensatz mit 20 Zeilen unsichtbar. Genau deshalb schaffen sie es in die Produktion.
Ändere eine Sache und miss erneut. Zwei Änderungen gleichzeitig, und du weißt nicht, welche gewirkt hat — oder ob die eine die andere aufhebt.
Nimm den größten Posten zuerst, nicht den hässlichsten Code. Die verschachtelte Funktion, die dich beim Lesen ärgert, kostet vielleicht 2 % der Laufzeit. Die harmlos aussehende Schleife daneben kostet 60 %.
Als Werkzeuge reichen für den Anfang zwei: Abfragen pro Request zählen und der Xdebug-Profiler für die Aufrufzahlen. Für beides brauchst du keine Lizenz. Kommerzielle Werkzeuge wie Blackfire oder Tideways setzen später an: Sie messen im laufenden Produktivbetrieb, wo Xdebug wegen seines Overheads ausscheidet, trennen Rechen- von Wartezeit und halten die Entwicklung über Releases hinweg fest. Für den ersten Befund brauchst du sie nicht.
Was dieser Artikel nicht behandelt
Bewusst ausgeklammert ist die Betriebsseite: OPcache-Einstellungen, JIT, Preloading, realpath_cache, die Dimensionierung der FPM-Pools. Die behandelt der Beitrag zur PHP-Konfiguration — dort optimiert man die Umgebung, hier den Code. Beides zusammen in einen Artikel zu packen hilft niemandem, weil kaum jemand beide Fragen gleichzeitig hat.
Ebenfalls nicht behandelt: Architekturfragen wie Warteschlangen, asynchrone Verarbeitung oder horizontale Skalierung. Die werden relevant, nachdem die acht Muster oben ausgeräumt sind — vorher kauft man sich damit nur teurere Hardware für denselben Fehler.
Häufige Fragen (FAQ)
Warum ist mein PHP-Skript in der Produktion langsam, lokal aber schnell?
Fast immer wegen der Datenmenge. Muster wie N+1-Abfragen oder quadratisches Wachstum sind auf einem Testdatensatz mit wenigen Zeilen nicht messbar und werden erst ab einigen tausend Datensätzen spürbar. Der zweite häufige Grund ist Parallelität: lokal ruft ein Benutzer eine Seite auf, in der Produktion tun es viele gleichzeitig — dann fallen Sperren auf, etwa die der Session.
Wie erkenne ich eine N+1-Abfrage?
Zähle die Datenbankabfragen pro Seitenaufruf und ändere dann die Anzahl der angezeigten Zeilen. Steigt die Abfragezahl mit der Zeilenzahl, liegt eine N+1-Situation vor. Diese Messung ist zuverlässiger als jedes Profiling, weil sie die Ursache direkt zeigt und nicht nur ihre Symptome.
Bringt es etwas, String-Verkettung in Schleifen durch implode zu ersetzen?
Für reines Anhängen mit .= nicht. PHP hängt amortisiert in konstanter Zeit an; der Rat stammt aus anderen Sprachen und hält sich hartnäckig. Sammeln und implode() ist dann sinnvoll, wenn du die Teile ohnehin einzeln brauchst oder ein Trennzeichen einfügen willst — als Performance-Maßnahme für simples Anhängen bringt es nichts.
Wann lohnt sich ein Cache?
Wenn dieselbe Berechnung mit denselben Eingaben mehrfach läuft und ihr Ergebnis länger gültig ist als der Zeitraum zwischen den Aufrufen. Beginne mit einem statischen Zwischenspeicher innerhalb des Requests — der kostet eine Zeile und braucht keine Invalidierung. Erst wenn das nicht reicht, kommt ein prozessübergreifender Cache in Frage, und mit ihm die Frage, wann er falsch wird.
Blockiert die Session wirklich parallele Requests?
Ja, beim standardmäßigen dateibasierten Session-Handler. Von session_start() bis zum Skriptende hält PHP eine exklusive Sperre auf der Session-Datei; weitere Requests desselben Benutzers warten. Ein session_write_close() direkt nach dem Lesen löst das für alle Endpunkte, die nichts in die Session schreiben.
Was messe ich zuerst, wenn ich keine Ahnung habe, wo es klemmt?
Zwei Zahlen: die Anzahl der Datenbankabfragen pro Request und den Spitzen-Speicherverbrauch. Beide sind in wenigen Minuten erhoben und schließen zusammen die Hälfte der möglichen Ursachen aus. Erst danach lohnt sich ein Profiler, der dir die Verteilung der Laufzeit über die Funktionen zeigt.
Hilft eine neuere PHP-Version gegen diese Probleme?
Bei der reinen Ausführungsgeschwindigkeit ja — die Sprünge von PHP 5 auf 7 und weiter auf 8 waren erheblich. Gegen die acht Muster oben hilft sie nicht: eine N+1-Abfrage bleibt eine N+1-Abfrage, und eine gesperrte Session bleibt gesperrt. Ein Versionswechsel ist trotzdem sinnvoll, nur eben aus anderen Gründen.
Wo du weitermachst
Wenn du die acht Muster im eigenen Code durchgehen willst und dabei jemanden dabei haben möchtest, der die Messung mitaufsetzt: das Seminar PHP professionell programmieren mit KI geht genau diesen Weg — messen, Ursache benennen, gezielt ändern, nachmessen. Wer die Muster erst einmal sicher schreiben lernen will, ist bei PHP-Programmierung Grundlagen richtig; wer KI-Werkzeuge im Alltag einsetzt und wissen will, wo sie bei Performance-Fragen helfen und wo sie zuverlässig danebenliegen, bei KI und VibeCoding für PHP-Entwickler.
Und wenn die Datenbankseite der Engpass ist — was sie nach Muster 1 und 2 häufig wird —, lohnt der Blick in die PostgreSQL-Konfiguration.
Alle Beispiele in diesem Artikel laufen mit PHP 8 ohne zusätzliche Bibliotheken. Du brauchst weder ein neues Framework noch größere Hardware, um sie anzuwenden — nur die Bereitschaft, vor der ersten Änderung einmal zu messen.