Stell dir den Bug-Report vor, der an einem Freitagnachmittag auf Staging aufschlägt: Ein ausgeloggter Smoke-Test bekommt die Account-Daten einer anderen Testerin serviert. Niemand hat den Auth-Code angefasst. Geändert hat sich die Laufzeitumgebung, denn das Team hat gerade eine Symfony-App auf den Worker-Modus von FrankenPHP umgestellt. Hier ist meine Position, gleich vorweg: Dieser Bug ist kein Argument gegen FrankenPHP. Er ist das beste Einzelargument dafür, es wenigstens auszuprobieren. Zwei Jahrzehnte lang hat PHPs Abriss nach jedem Request still und leise unsere schlampigsten Angewohnheiten aufgefangen, und ich glaube, diesen Freifahrtschein zu verlieren ist eines der gesündesten Dinge, die einer gereiften Codebasis passieren können.

worker.php
<?php
// Deliberately broken: static state outlives the request.
final class RequestContext
{
    public static ?int $userId = null;
}

$handler = static function (): void {
    if (isset($_GET['user_id'])) {
        RequestContext::$userId = (int) $_GET['user_id'];
    }
    echo 'Current user: ' . (RequestContext::$userId ?? 'guest');
};

while (frankenphp_handle_request($handler)) {
}

Kurze Einordnung für alle, die den Namen bisher nur im Vorbeiflug gesehen haben. FrankenPHP bettet den offiziellen PHP-Interpreter per CGO direkt in Caddy ein und führt deinen Code auf einem Pool von POSIX-Threads aus. Kein PHP-FPM-Daemon, kein FastCGI-Socket, ein Prozess, der TLS, HTTP/2 und HTTP/3, statische Dateien und PHP in einem einzigen Log-Stream bedient. Im klassischen Modus verhält es sich wie das Setup, das du schon kennst: Jeder Request startet frisch, und du kannst es unter eine bestehende App schieben, ohne irgendetwas umzuschreiben. Der Worker-Modus ist der interessante Teil. Deine Anwendung bootet einmal, bleibt dann im Speicher und beantwortet Request um Request aus demselben Prozess.

Und jetzt überleg mal, was das traditionelle Modell die ganze Zeit für dich erledigt hat. Jede statische Property, jedes memoized Singleton, jeder Config-Wert, den du irgendwo global hingestopft hast, wurde in dem Moment geschreddert, in dem die Response rausging. Dieser Abriss war ein nächtlicher Hausmeister, der hinter Code aufräumt, der nie gelernt hat, hinter sich selbst aufzuräumen. Der Worker-Modus feuert den Hausmeister. Statics überleben. Event-Listener, die du in einem Request-Handler registrierst, stapeln sich, einer mehr pro Request. Eine in einer Helper-Klasse gecachte Tenant-ID überlebt den Tenant. Selbst bei den Superglobals lohnt sich das Kleingedruckte: FrankenPHP setzt die meisten davon zwischen den Requests zurück, aber die Docs weisen aktuell darauf hin, dass $_ENV nicht zurückgesetzt wird. Umgebungsdaten musst du also als strikt unveränderlich behandeln und darfst sie nie benutzen, um Per-Request-Werte durch die Gegend zu schmuggeln.

Der Ausgangsartikel, auf den ich mich hier beziehe, zeigt das Versagen in elf Zeilen, und die sind es wert, sie anzustarren. Ein Request setzt eine User-ID auf eine statische Property. Der nächste Request schickt gar nichts und sieht trotzdem User 42, weil die Klasse im Speicher geblieben ist. Nichts davon ist ein Defekt in FrankenPHP. Den Prozess am Leben zu halten ist genau die Stelle, aus der der Geschwindigkeitsgewinn kommt, denn der Composer-Autoload, der Container-Build und die Kernel-Konstruktion passieren einmal statt bei jedem Hit. Laravels Octane-Integration liefert eine --max-requests-Option mit und recycelt Worker standardmäßig nach einer begrenzten Anzahl von Requests, was das Anwachsen des Speichers deckelt. Aber Recycling ist ein Rauchmelder. Er sagt dir, dass etwas brennt; er löscht das Feuer in deinem Code nicht.

Warum nenne ich das also einen Lügendetektor und nicht eine Gefahrenquelle? Weil jeder Defekt, den er ans Licht bringt, schon vorher ein Defekt war, nur mit Tarnkappe. Wenn sich deine App danebenbenimmt, sobald der Prozess länger lebt als ein Request, hast du mit ziemlicher Sicherheit dieselbe Fäulnis auch woanders: flatterhafte Testsuiten, die isoliert grün sind und in Sequenz rot werden, Queue-Worker, die einen nächtlichen Neustart brauchen, Speichergraphen, die immer nur nach oben zeigen. Du betreibst seit Jahren langlebiges PHP in deinen Consumern und Daemons. Der Worker-Modus wendet diese Disziplin nur auf die Web-Schicht an, und die Frameworks kommen dir auf halbem Weg entgegen: Octane installiert den FrankenPHP-Server mit zwei artisan-Kommandos, und Symfony unterstützt das Worker-Modell ab 7.4 nativ, mit einem PHP-Runtime-Paket für ältere Versionen.

Das ehrliche Gegenargument verdient sein volles Gewicht. FrankenPHP setzt thread-sicheres PHP voraus, und die Kompatibilitätsliste hat Löcher: imap, newrelic und pcov sind derzeit nicht unterstützt, und imagick kommt mit dokumentierten Einschränkungen. Wenn New Relic in deiner Observability-Geschichte tragend ist, kann das allein das Gespräch heute schon beenden. Die Performance-Docs steuern anspruchsvolle Workloads außerdem in Richtung Debian-Images, weil threaded PHP auf musl, das Alpine benutzt, messbar langsamer läuft. Und wenn dein Team eine gehärtete Plattform aus Nginx plus PHP-FPM betreibt, mit Jahren an Dashboards, Runbooks und On-Call-Reflexen dahinter, dann ist es ein schlechter Tausch, das rauszuwerfen, nur damit das Architekturdiagramm hübscher aussieht. Ein langweiliger CRUD-Monolith bei überschaubarem Traffic wird von alldem nichts spüren.

Ich lande trotzdem da, wo ich gestartet bin, und zwar aus einem Grund, der mit Durchsatz wenig zu tun hat. Fahr FrankenPHP im klassischen Modus und du bekommst die betriebliche Konsolidierung ganz für sich: eine Binary, automatische Zertifikate, Prometheus-Metriken für ausgelastete Threads, Request-Zeit und Queue-Tiefe, kein Grep mehr durch zwei Services, um einen einzigen fehlgeschlagenen Request zu rekonstruieren. Dann richte eine Staging-Umgebung auf den Worker-Modus aus und hämmere sie mit verschränkten Requests für verschiedene User, Tenants und Locales. Entweder hält sie stand, und du hast Latenz-Spielraum, der nur darauf wartet, eingesammelt zu werden. Oder sie leckt, und du besitzt jetzt eine präzise Landkarte jeder Stelle, an der deine Anwendung globalen Zustand mit Request-Zustand verwechselt. Beide Ergebnisse sind mehr wert als der Nachmittag, den sie kosten.

Und jetzt will ich es von dir wissen. Hast du den Worker-Modus tatsächlich schon gegen eine Codebasis eingeschaltet, die älter als fünf Jahre ist, und was ist dabei herausgekrochen? Ein statischer Logger, der einen Request-Kontext festhält, ein zehntausendfach registrierter Listener, etwas noch Seltsameres? Und an alle, die es probiert haben und zu PHP-FPM zurück sind: Waren es die Extension-Lücken, die Debugging-Erfahrung, oder haben die Isolations-Bugs schlicht mehr gekostet, als die Bootstrap-Ersparnis eingebracht hat? Die Kommentare sind offen, und ich vermute, die Kriegsgeschichten sind besser als jeder Benchmark.