Vor Jahren habe ich eine Codebasis geerbt: drei Webserver, ein gemeinsamer Redis und vierzehn geplante Tasks. Dreizehn davon hatten ->onOneServer() am Ende. Der vierzehnte verschickte eine wöchentliche Rechnungsübersicht, also bekamen jeden Montag um 06:00 einige Kunden dieselbe Mail dreimal, im Abstand von ein paar Sekunden. Niemand hatte eine seltsame Entscheidung getroffen. Jemand hatte an einem Freitag einen Task hinzugefügt und ihn aus einem Projekt kopiert, das auf einer einzigen Maschine lief. Als ich die Release Notes zu Laravel 13.35 gelesen habe, getaggt am 6. Oktober, bin ich am neuen HTTP-Verb vorbeigescrollt und direkt zu Schedule::alwaysOnOneServer() gesprungen, weil mich genau dieser Bug ein Wochenende gekostet hatte.

Meine These: Die wertvollsten Änderungen in diesem Release nehmen etwas, das du bei jedem Task oder jedem Request wiederholen musstest, und machen daraus eine Einstellung, die du einmal setzt. Du rufst Schedule::alwaysOnOneServer() in einem Service Provider auf, und jedes geplante Event bekommt den Single-Server-Lock. Du aktivierst FormRequest::failOnUnknownFields() global, und jeder Form Request lehnt Eingaben ab, die in seinen Regeln nicht vorkommen. Seit 13.35 ignoriert der Strict Mode außerdem die Felder _token und _method, die Blades @csrf und @method('PUT') hinzufügen. Vor diesem Fix ist ein strikter Request an einem ganz normalen PUT-Formular gescheitert, wodurch der globale Schalter für serverseitig gerenderte Apps fast unbrauchbar war. Jetzt passt er zur häufigsten Laravel-App überhaupt.

Diese Schalter sind wichtig, weil eine Regel, an die man bei jedem Aufruf denken muss, nicht mit der Zahl der Leute skaliert, die Code schreiben. Ein vergessener Modifier in einem Pull Request reicht, und im Review fällt nicht auf, was fehlt. Eine globale Einstellung verlagert die Entscheidung von jeder Stelle, an der du einen Task oder Request schreibst, an die eine Stelle, an der deine App bootet. Außerdem kannst du sie prüfen. schedule:list --json hat jetzt für jedes Event ein Feld on_one_server, sodass ein zehnzeiliger CI-Check den Build scheitern lassen kann, wenn ein Task abweicht. Vorher hieß dieser Check, nach einem Methodenaufruf zu greppen und zu hoffen, dass ihn niemand in einen Helper verpackt hat.

Der stärkste Einwand verdient eine faire Anhörung, und er lautet nicht „Breaking Changes sind gruselig“. Globale Schalter verstecken Verhalten. Sobald alwaysOnOneServer() aktiv ist, verrät dir die Definition eines Tasks nicht mehr, wie er läuft, und es gibt Tasks, die du wirklich auf jedem Node haben willst: ein lokales Temp-Verzeichnis leeren, Logs pro Host rotieren, einen Datei-Cache in der Nähe von OPcache vorwärmen. Dazu kommt eine scharfe Kante in der Implementierung. Closures, die mit Schedule::call() geplant werden, überspringt der globale Schalter, solange sie keinen Namen haben, was der dokumentierten Regel für Single-Server-Closures folgt. Wer aber den Provider liest, nimmt an, dass „always“ auch immer heißt. Und der Lock hängt an einem gemeinsamen Cache-Store: database, memcached, dynamodb oder redis, erreichbar für jeden Server. Schaltest du das mit dem file-Cache-Treiber ein, bekommst du überhaupt keinen Schutz.

Das alles akzeptiere ich. Mein Schluss daraus ist aber, dass es für bessere Notausgänge und lautere Fehler spricht, nicht dafür, die Last beim Aufrufer zu lassen. Eine Möglichkeit pro Task, „lauf auf jedem Node“ zu sagen, plus eine Warnung beim Booten, wenn der globale Schalter an ist und der Cache-Treiber keinen gemeinsamen Lock halten kann, würde das meiste lösen. Der Fall der unbenannten Closure sollte wahrscheinlich eine Exception werfen, statt still darauf zurückzufallen, auf jedem Server zu laufen. Mir ist ein Framework, das standardmäßig sicher ist und mich bewusst aus der Sicherheit aussteigen lässt, viel lieber als eines, bei dem der sichere Weg von meinem Gedächtnis um 17:40 an einem Freitag abhängt.

Der Rest des Releases stützt dasselbe Argument von einer anderen Seite. chunkById() und lazyById() liefen früher bei Models mit gecastetem Primärschlüssel in eine Endlosschleife. withCasts() auf einer geklonten Query hat früher das Original verändert. Zirkuläre Referenzen im Standard-Callback von PendingRequest::throw() und in den Callbacks von headers() und priority() in Mailables haben in langlaufenden Workern still Speicher festgehalten. Nichts davon hat eine Schlagzeile bekommen, und jedes davon ist eine Falle, der du nur ausweichen konntest, wenn du schon von ihr wusstest. Die neuen Opt-in-Bausteine folgen demselben vorsichtigen Muster. lazy_root_creation für lokale Disks steht standardmäßig auf false, und der Trait HasDefaultAttributes tut nichts, bis du ihn einbindest. Für ein Minor-Release ist das richtig. Minor-Versionen sind dafür da, Schalter hinzuzufügen, und in Major-Versionen ändert man, in welche Richtung sie zeigen.

Mein Wunsch für Laravel 14 ist also ganz konkret. Macht Single-Server-Scheduling zum Standard, sobald der konfigurierte Cache-Store Locks unterstützt, mit einem expliziten Opt-out pro Task. Gebt neuen Projekten strikte Form Requests von Haus aus, jetzt wo das Blade-Problem erledigt ist. Den Rest lasst, wie er ist. Route::query() ist nett, aber die Unterstützung für QUERY bei Clients, Proxys und CDNs ist noch uneinheitlich, deshalb sollte das bei niemandem das Upgrade bestimmen. Führ diese Woche trotzdem composer update laravel/framework aus und lass deine Tests laufen, vor allem wenn du morphTo, HasOneThrough oder JSON-Casts nutzt.

Jetzt will ich deine Meinung hören, vor allem wenn du Laravel auf mehr als einer Maschine betreibst. Wenn alwaysOnOneServer() morgen zum Standard würde, welche deiner geplanten Tasks würden kaputtgehen, weil sie auf jedem Server laufen müssen, und würdest du lieber diese Tasks markieren oder die anderen neunzig Prozent?