Zwanzig aufeinanderfolgende Requests, zwanzig frische MySQL-Verbindungen. Das ist PHP-FPM, das genau das tut, wofür es gebaut wurde: Es gibt dir einen sauberen Prozess und brennt alles nieder, sobald die Response raus ist. Dieselben zwanzig Requests gegen FrankenPHP im Worker-Modus öffneten null neue Verbindungen, weil die eine, die beim Start aufgebaut wurde, sie alle bediente. Die Messung stammt aus einem französischsprachigen Erfahrungsbericht über die Migration einer Sulu-Site, und es ist der überzeugendste FrankenPHP-Text, den ich dieses Jahr gelesen habe, gerade weil seine Kernzahl klein ist.

Klein heißt: 30 bis 40 Millisekunden weniger bei einer Response, die jetzt bei etwa 155 ms liegt. Nicht der Faktor zehn von den Konferenzfolien. Und genau diese Bescheidenheit ist der Grund, der Zahl zu trauen. Der Worker-Modus entfernt eine einzige Sache: die Aufbauarbeit, die dein Framework bei jedem Request wiederholt. Er tut nichts für dein SQL, nichts fürs Template-Rendering, nichts für deine Domänenlogik. Wenn eine Seite 400 ms in der Datenbank verbringt, schaut der Worker-Modus höflich zu, wie sie 400 ms in der Datenbank verbringt. Wer mehr verspricht, verkauft dir sein Benchmark-Setup.

Der naheliegende Einwand: Das cachen wir doch längst. Stimmt, nur deckt es den teuren Teil nicht ab. OPcache hält kompilierten Bytecode im Shared Memory, PHP parst deine zweitausend Dateien also nicht ständig neu. Symfonys kompilierter Container in var/cache/prod/ sorgt dafür, dass das Framework kein YAML mehr einliest. Aber unter FPM werden die Objekte, die aus all dem gecachten Code gebaut werden, die Kernel-Instanz, der verdrahtete Container, Doctrines Entity-Metadaten, die offene Datenbankverbindung, am Ende jedes Requests abgerissen und am Anfang des nächsten neu errichtet. Mehrere Megabyte vorbereiteter Zustand, zusammengesetzt und weggeworfen, hunderte Male pro Minute. Der Worker-Modus hält diese Objekte über hunderte Requests am Leben. Das ist der ganze Trick, und er reicht.

Jetzt der ehrliche Teil, denn ein Prozess, der sich Dinge merkt, merkt sich auch deine Fehler. Das Backoffice aus dem Bericht ging auf lehrreiche Weise kaputt: Sulu entscheidet beim Kernel-Boot, ob es die öffentliche Site oder den Admin bedient, ein Worker friert diese Entscheidung für immer ein, und die mitgelieferte index.php enthält ein exit, das eine Worker-Schleife tot umfallen lässt. Ergebnis: /admin lieferte in Produktion 404. Der Fix bestand darin, den Admin über einen klassischen Front-Controller zu routen und nur die öffentliche Site im Worker zu behalten. Speicher ist die andere Sorge, und hier sind die Zahlen beruhigend statt triumphal: 166 MB kurz nach einem Restart, 271 MB nach rund 18 Stunden Traffic, mit einem Plateau deutlich unter der Grenze von 1 GB. Drei Leitplanken sorgen für dieses Plateau: Der Symfony-Runner ruft nach jedem Request gc_collect_cycles() auf, FrankenPHP recycelt jeden Worker standardmäßig nach 500 Requests (einstellbar über FRANKENPHP_LOOP_MAX), und das Speicherlimit des Containers fängt ab, was trotzdem entkommt. Niemand behauptet, deine Leaks verschwinden. Das System weigert sich nur, sie endlos auflaufen zu lassen, und das ist die erwachsene Version des Versprechens.

Der operative Haken ist der, den ich jedem Deploy-Skript eintätowieren würde: frankenphp reload ersetzt deine Worker nicht. Es lädt Caddys Konfiguration neu und lässt drei Prozesse zurück, die einen Kernel halten, der auf einen Service-Container zeigt, den dein cache:clear gerade gelöscht hat. Die Site antwortet mit 500, bis jemand einen echten Restart ausführt, in diesem Setup docker compose restart php. Dazu kommt opcache.validate_timestamps auf 0, was PHP davon abhält, pro Request tausende Dateien per stat() zu prüfen, aber auch bedeutet, dass Hand-Edits auf dem Server stillschweigend nichts bewirken. Beide Einstellungen sind nur sicher, weil das Deployment PHP automatisch neu startet. Ich höre schon den Einwand, das sei fragil. Ich würde es umdrehen: Wenn dein Prozess immer noch davon abhängt, Dateien auf einer laufenden Kiste zu editieren und zu hoffen, dann hat der Worker-Modus dieses Risiko nicht geschaffen, er hat nur aufgehört, es zu kaschieren.

Unter der Worker-Geschichte liegt ein leiserer Gewinn. FrankenPHP ist Kévin Dunglas, der die PHP-Engine in Caddy einbettet, den Go-Server, den Matthew Holt 2014 gestartet hat, sodass ein einziges Binary jetzt TLS terminiert, HTTP/1.1 bis HTTP/3 spricht und statische Dateien ausliefert. Das Team hinter dem Bericht hat nginx komplett rausgeworfen, und es stellte sich heraus, dass ausgerechnet das Rate Limiting von nginx die Verlangsamung verursacht hatte, die die ganze Untersuchung ausgelöst hat. Eine Config zum Lesen statt zwei Orte, an denen etwas falsch sein kann. Mein Lieblingsdetail: vorkomprimierte Brotli-Assets, einmal beim Deploy in maximaler Qualität gebaut. Ihr Stylesheet ging von 88 KB roh über 24 KB bei Kompression zur Laufzeit auf 14 KB vorkomprimiert. Und ja, es hat etwas Befriedigendes, wenn PHP sich Gos Concurrency-Maschinerie komplett ausleiht, Goroutinen inklusive, ohne dass jemand einen Controller umschreiben muss.

Am meisten beeindruckt mich, was sie gemessen und dann abgelehnt haben. Der JIT: Gewinn nahe null, weil eine Webanwendung in I/O sowie String- und Array-Arbeit lebt, nicht in numerischen Schleifen, also bleibt er aus. opcache.preload: überflüssig, wenn drei residente Worker den Kernel ohnehin warm halten, und riskant mit Sulus Annotation-Auswertung. Ein zweiter HTTP-Cache in Caddy: verworfen, weil Symfonys Cache schon weiß, wie er sich selbst invalidiert, und ein zweiter Cache einen zweiten Invalidierungspfad bedeutet, den man ehrlich halten muss. Das ist Engineering als Subtraktion, und das ist seltener, als es sein sollte.

Die Bilanz lautet also: von rund 190 ms runter auf 155 ms, etwa 140 MB RAM dauerhaft von drei Workern belegt, und eine Deploy-Pipeline, die PHP ohne Menschen in der Schleife neu starten muss. Auf dieser Grundlage würde ich den Tausch bei den meisten Symfony- oder Laravel-Apps mit Speicherreserven eingehen und ihn bei allem auslassen, was sich auf akkumulierende Statics oder verstreute exit-Aufrufe stützt. Aber ich weiß, meine Schwelle ist nicht deine. Würdest du 140 MB dauerhaft belegten Speicher hergeben, um 35 ms pro Request zu sparen, und falls du den Schalter schon umgelegt hast: Was ging bei dir als Erstes kaputt, als sich deine Anwendung plötzlich an den vorherigen Request erinnert hat?