Legacy PHP

Laravel Major-Upgrade ohne Big Bang: von 9 auf 13, einen Schritt nach dem anderen

Laravel-Upgrades sind mechanisch gut machbar. Der teure Fehler ist nicht die Migration selbst, sondern der Versuch, mehrere Major-Versionen auf einmal zu nehmen. Warum ein Major pro Schritt die einzige sichere Reihenfolge ist, warum die abandoned Packages der eigentliche Blocker sind, und ein Blick darauf, was pro Version typischerweise bricht.

15. September 2026 10 min · 1.837 Wörter

Zwischen Laravel 9 und 13 liegen vier Major-Versionen. Wer den Sprung in einem Rutsch macht, nimmt sich nicht ein Upgrade vor, sondern vier gleichzeitig: Die Breaking Changes aller vier Versionen treffen dieselbe Codebasis auf einmal. Bricht danach etwas, lässt sich nicht mehr sagen, welcher Sprung die Ursache war. Deshalb nehme ich Major-Upgrades einzeln, einen Schritt nach dem anderen.

Dabei ist ein Laravel-Upgrade an sich eine der dankbareren Aufgaben in der Legacy-Arbeit. Es gibt einen offiziellen Upgrade-Guide pro Version, die Breaking Changes sind dokumentiert, und Werkzeuge nehmen einem die stumpfe Fleißarbeit ab. Das eigentliche Risiko liegt nicht in der Migration, sondern in der Reihenfolge und im Tempo. Also der Reihe nach.

Ohne Tests kein Upgrade

Der erste Punkt ist keine Laravel-Frage, sondern die Bedingung für alles Weitere: Ohne eine Testsuite, die das aktuelle Verhalten absichert, ist jedes Major-Upgrade eine Wette. Man ändert die Grundlage der Anwendung und hat nichts, das einem sagt, ob danach noch alles tut, was es vorher tat. Wie man in einer übernommenen Laravel-App überhaupt zu dieser Absicherung kommt, habe ich im vorigen Logbuch beschrieben. Alles, was jetzt kommt, setzt voraus, dass dieses Netz steht.

Der Grund ist einfach: Ein Upgrade produziert im besten Fall keine sichtbaren Änderungen, sondern nur dieselbe Anwendung auf neuerem Unterbau. Ob das so ist, kann nur ein Test beweisen, der vorher grün war und danach immer noch grün ist. Ohne ihn merkt man den Bruch erst, wenn ein Nutzer anruft.

Wie dringend ist es wirklich?

Bevor man loslegt, lohnt der nüchterne Blick auf die Frage, wie eilig das Ganze überhaupt ist. Laravel folgt seit Version 9 einem jährlichen Rhythmus und hat die früheren LTS-Versionen abgeschafft: Einmal im Jahr erscheint eine neue Major-Version, und für jede gilt dieselbe Regel, 18 Monate lang Bugfixes und 2 Jahre lang Sicherheitskorrekturen, danach ist sie aus dem Support. Die konkreten Enddaten pro Version stehen in der offiziellen Support-Tabelle, dort schaut man für den eigenen Fall nach.

Dieses Datum ist die eigentliche Deadline, nicht die Existenz einer neueren Version. Eine App zwei Versionen hinter der aktuellen ist kein Problem, solange sie noch Sicherheitsfixes bekommt. Eine App, deren Version aus dem Security-Support gefallen ist, ist eins, auch wenn sie tadellos läuft. Die Frage lautet also nicht „sind wir auf der neuesten Version", sondern „bekommt unsere Version noch Sicherheitskorrekturen, und wenn nein, wie lange ist das schon so". Das ordnet die Dringlichkeit realistisch ein und nimmt den Druck, immer sofort auf dem allerletzten Stand sein zu müssen.

Wo die App aktuell steht, verrät ein einziger Befehl: php artisan about listet die Laravel- und PHP-Version zusammen mit der Umgebung auf. Diesen Wert gleicht man mit der Support-Tabelle ab und weiß dann, ob man Zeit hat oder eine Deadline im Nacken.

Ein Major nach dem anderen

Der Kern der ganzen Sache ist eine simple Regel: immer nur eine Major-Version pro Schritt. Von 9 auf 10, Tests grün, Zwischenstand sichern. Dann von 10 auf 11, Tests grün, sichern. Und so weiter, bis man auf der Zielversion angekommen ist. Ob nach jedem Schritt auch deployt wird, hängt vom Release-Prozess ab, dazu weiter unten mehr.

Der Ablauf pro Sprung ist jedes Mal derselbe. Den offiziellen Upgrade-Guide der Zielversion durchgehen. Die Versions-Constraints in der composer.json anheben. composer update laufen lassen. Die Testsuite ausführen, bis sie grün ist. Committen, deployen, kurz beobachten. Erst dann der nächste Major.

So sieht der eine Schritt in der composer.json aus, bewusst nur einer:

"require": {
    "php": "^8.2",
    "laravel/framework": "^11.0"
}

Nicht ^13.0 eintragen und hoffen. Ein Constraint, ein Sprung, ein Commit. Der Gewinn dieser Kleinschrittigkeit ist der, den man erst zu schätzen weiß, wenn etwas schiefgeht: Bricht nach einem Schritt ein Test, liegt die Ursache in genau dieser einen Version, nicht in einem Knäuel aus vier. Man kann den Commit isoliert zurückrollen und weiß sofort, wo man sucht.

Den Upgrade-Guide liest man dabei nicht wie einen Roman. Er ist nach Auswirkung sortiert, die wahrscheinlichen Breaking Changes stehen oben, die Randfälle unten. Man hakt die High-Impact-Punkte gegen die eigene App ab und überfliegt den Rest. Und man vergisst die Laravel-eigenen Pakete nicht: Wer Sanctum, Horizon, Telescope oder Cashier einsetzt, muss deren Versionen im selben Schritt mitziehen, weil sie an die Framework-Version gebunden sind. Ein Framework-Sprung mit veralteten First-Party-Paketen daneben geht selten glatt.

Werkzeuge wie Laravel Shift automatisieren die mechanische Seite dieser Sprünge und sparen echte Zeit. Was sie nicht abnehmen, ist die Entscheidung, ob das Ergebnis stimmt. Diese Entscheidung trifft die grüne Testsuite, kein Tool. Shift bringt einen schneller an den Punkt, an dem man den Test laufen lässt, aber der Test bleibt das, was zählt.

Der wahre Blocker sind die Pakete

Wenn ein Upgrade wirklich stecken bleibt, liegt es selten am Framework selbst. In meiner Erfahrung ist fast immer ein Drittpaket schuld. Ein einziges Package, das für die Zielversion keine kompatible Fassung hat, hängt wie ein Anker am Rumpf und hält die ganze Anwendung auf der alten Version fest, egal wie sauber der Rest wäre.

Deshalb gehört die Paketlage geklärt, bevor der erste Sprung beginnt, nicht mittendrin. Für jedes Paket, das den Weg blockiert, gibt es drei Wege. Ersetzen durch eine gepflegte Alternative, die dasselbe leistet. Forken und selbst kompatibel machen, wenn das Paket klein und der Quelltext überschaubar ist. Oder, im seltenen Fall, die benötigte Funktion in den eigenen Code holen und das Paket ganz entfernen. Welcher Weg der richtige ist, entscheidet sich pro Paket. Wichtig ist nur, dass diese Arbeit vor dem Versionssprung passiert und nicht als Überraschung mitten darin.

Ob ein Paket den Weg blockiert, muss man dabei nicht raten. composer why-not laravel/framework ^11.0 zeigt für einen geplanten Sprung, welche Abhängigkeiten den Solver von der Zielversion abhalten und welche Constraints dabei im Weg sind, oft auch die geforderte PHP-Version. So sieht man die Blocker, bevor man den ersten Schritt macht, statt mitten im composer update von einer Fehlermeldung überrascht zu werden. Ein zweiter nützlicher Blick trennt Entwicklungs- von Produktivabhängigkeiten: Ein Blocker, der nur in require-dev steckt, etwa ein Test- oder Debug-Paket, hält die Produktionslaufzeit nicht auf. Die Entwicklungs- und CI-Umgebung kann er trotzdem blockieren, aber lösen lässt er sich meist getrennt vom eigentlichen Versionssprung.

Ein abandoned-Marker in der composer.json ist übrigens noch kein Blocker. Manche dieser Pakete laufen problemlos auf neueren Versionen weiter. Aber sie sind die erste Stelle, an der man nachsieht, wenn composer update sich weigert, und deshalb kartiert man sie am besten vorab.

Was pro Major typischerweise bricht

Jede Major-Version hat ihre eigenen Stellen, an denen gewachsene Apps hängen bleiben. Zwei Muster tauchen dabei immer wieder auf.

Das erste sind angehobene Mindestanforderungen. Neuere Laravel-Versionen setzen regelmäßig eine höhere PHP-Version voraus, was den PHP-Upgrade-Pfad mit dem Laravel-Pfad verzahnt. Man kann das eine nicht ohne das andere planen, und manchmal ist das PHP-Upgrade selbst der aufwändigere Teil.

Das zweite sind strukturelle Umbauten am Anwendungsgerüst. Der auffälligste ist die schlanke Skeleton-Struktur, die mit Laravel 11 kam: kein app/Http/Kernel.php mehr, stattdessen wird die Konfiguration zentral in der bootstrap/app.php gebündelt. Gewachsene Apps mit viel eigener Middleware und angepasstem Bootstrapping hängen genau hier, weil ihr über die Jahre gewachsenes Setup nicht auf die neue Struktur passt.

Wichtig dabei, und das wird oft übersehen: Man muss die neue Skeleton-Struktur nicht übernehmen, um die neue Version zu nutzen. Die alte Struktur läuft weiter. Der Wechsel auf bootstrap/app.php ist ein eigener, optionaler Schritt, den man später gehen kann, nicht Teil des Versionssprungs.

Viele kleinere Änderungen zwischen Versionen sind rein mechanisch, und manche sind gar keine Pflicht, sondern nur eine neue, empfohlene Schreibweise. Ein Beispiel ist die casts()-Methode, die neuere Versionen als Alternative zur bekannten $casts-Property anbieten:

// Bestehende Form
protected $casts = [
    'options' => 'array',
    'active'  => 'boolean',
];

// Neuere Form, ab Laravel 11 verfügbar
protected function casts(): array
{
    return [
        'options' => 'array',
        'active'  => 'boolean',
    ];
}

Beide Formen funktionieren, die Methode ersetzt die Property nicht zwingend. Wer aber auf die neue Schreibweise umstellt, merkt schnell, dass so eine harmlose Änderung nur solange harmlos ist, wie sie an einer Stelle steht. Wird $casts in einer Basisklasse überschrieben, von der dreißig Models erben, ist aus der Fünf-Minuten-Sache ein halber Tag geworden. Welche dieser Stellen es in der eigenen App gibt, sagt einem wieder nur der Test, der vorher grün war.

Ein dritter wiederkehrender Punkt sind die Config-Dateien. Laravel bringt seine Standard-Konfiguration mit, aber gewachsene Apps haben ihre config-Dateien über die Jahre angepasst. Wenn eine neue Version dort Optionen ergänzt oder umbenennt, muss man die eigenen Anpassungen mit den neuen Vorgaben abgleichen, statt die mitgelieferte Datei einfach zu überschreiben. Das ist Handarbeit, die kein Tool zuverlässig abnimmt, weil nur man selbst weiß, welche Abweichung Absicht war und welche ein Altlast-Zufall.

Nach jedem Sprung prüfen

Zwischen zwei Sprüngen lohnt ein kurzer, immer gleicher Kontrollblick, der über das reine „die Tests sind grün" hinausgeht.

Zuerst die Deprecations. Ein Test kann grün sein und die Anwendung trotzdem im Log vor Verhalten warnen, das in der nächsten Version wegfällt. Wer diese Warnungen schon jetzt abarbeitet, macht sich den übernächsten Sprung leichter, statt die Schuld weiterzuschieben.

Dann die PHP-Version. Getestet wird gegen genau die PHP-Version, die in Produktion läuft, nicht gegen die, die zufällig auf dem Entwickler-Rechner installiert ist. Sonst beweist ein grüner Lauf etwas über eine Umgebung, die es auf dem Server gar nicht gibt. Es ist dieselbe Regel wie bei der Testdatenbank: Die Testumgebung ist nur so viel wert, wie sie der Produktion gleicht.

Stolperfallen

composer update mit --with-all-dependencies blind laufen lassen. Das zieht im Zweifel gleich mehrere andere Pakete mit hoch, und schon sind mehrere Upgrades in einem Commit vermischt. Auch bei den Abhängigkeiten gilt: so gezielt wie möglich, damit nachvollziehbar bleibt, was sich geändert hat.

Die schlanke Skeleton-Struktur nachbauen wollen, obwohl die App gewachsen ist. Das ist der Reflex, bei der Gelegenheit gleich alles auf den neuesten Stand zu bringen. Aber die neue Struktur ist kein Bestandteil des Upgrades. Wer beides vermischt, weiß hinterher nicht, ob ein Fehler vom Versionssprung kommt oder vom Umbau des Bootstrappings.

Das ganze Upgrade als ein einziger Riesen-PR. Vier Major-Versionen, alle Paket-Ersetzungen und der Skeleton-Umbau in einem Branch, der zwei Wochen offen liegt. Wenn der zusammenbricht, gibt es nichts, was man sauber zurücknehmen kann. Ein Major, ein nachvollziehbarer PR, ein bewusst gesetzter Zwischenstand, und wo der Release-Prozess es hergibt auch ein eigener Deploy. Nicht jede Organisation deployt nach jedem einzelnen Schritt, aber jeder Schritt sollte für sich zurücknehmbar bleiben. Der Rollback ist nur so viel wert, wie er klein ist.


Ein Laravel, das mehrere Versionen im Rückstand ist, wirkt oft wie ein großer Brocken, ist aber meistens eine Kette überschaubarer Schritte, sobald die Blocker-Pakete auf dem Tisch liegen. Wenn Sie nicht sicher sind, wo Ihr Upgrade-Pfad anfängt und was Sie unterwegs erwartet: Schildern Sie mir kurz Ihre Ausgangslage, ich melde mich mit einer ersten Einschätzung. Termin buchen.