„Ist doch Laravel, das ist modern, da muss man nicht viel machen." Diesen Satz höre ich in Übernahmegesprächen fast reflexhaft, und ich verstehe, woher er kommt. Laravel hat einen guten Ruf, die Doku ist erstklassig, das Framework nimmt einem viel ab. Nur steckt das Missverständnis im Wort modern. Eine aktuelle Framework-Version sagt nichts darüber, wie der Code aussieht, der über die Jahre darin gewachsen ist.
Eine Anwendung kann auf einer gepflegten Laravel-Version laufen und trotzdem Controller mit mehreren hundert Zeilen haben, Geschäftslogik mitten in Blade-Templates, Models mit Dutzenden Relationen und keinen einzigen Test. Die Version ist modern. Der Code deshalb noch lange nicht. Und weil Laravel so einladend ist, wird an den Konventionen oft besonders bequem vorbeigebaut. Die Struktur lädt dazu ein, alles schnell in einen Controller zu kippen, wenn niemand darauf achtet.
Genau diese Art von Übernahme meine ich: Ein Laravel-Projekt, das zwar läuft, aber niemand mehr anfassen will, weil keiner weiß, was bei einer Änderung passiert. Mein Vorgehen in den ersten Wochen ist fast wie bei einem alten PHP-Monolithen. Nur die Werkzeuge sind andere, und ehrlicherweise ein ganzes Stück angenehmer.
Framework-Version ist nicht Code-Qualität
Der erste Reflex nach einer Übernahme ist oft: „Dann heben wir das erstmal auf die aktuelle Laravel-Version." Klingt nach Fortschritt. Ist an dieser Stelle aber der falsche erste Schritt, und zwar aus einem simplen Grund: Ohne Tests weiß niemand, ob nach dem Upgrade noch alles tut, was es vorher tat. Ein Versionssprung ohne Sicherheitsnetz ist keine Modernisierung, sondern eine Wette darauf, dass die Breaking Changes schon niemanden treffen werden.
Bei Laravel sitzt das Ungetestete meistens an denselben Stellen. Da ist das eine Model, das über die Jahre zum Sammelbecken geworden ist: Dutzende Relationen, Scopes und Accessoren, und an ganz anderer Stelle löst es still Nebenwirkungen aus. Da ist Geschäftslogik, die in Event-Listenern und Model-Observern steckt, wo man sie beim Lesen des Controllers nie vermuten würde. Da sind Facades, überall direkt aufgerufen, die jede Abhängigkeit unsichtbar machen: bequem beim Schreiben, zäh beim Testen. Und da sind Controller, die Anwendungslogik enthalten, statt sie nur zu koordinieren. Nichts davon sieht man der Framework-Version an. Aber alles davon macht die Anwendung schwer und riskant zu ändern.
Der Wert einer übernommenen Anwendung liegt nicht in ihrer Framework-Version. Er liegt in dem Verhalten, das sie über Jahre für echte Nutzer produziert hat, inklusive all der stillen Sonderfälle, die niemand mehr aufschreibt. Meine Aufgabe in den ersten Wochen ist, dieses Verhalten sichtbar und überprüfbar zu machen. Erst danach reden wir über Versionen, Architektur und alles andere.
Zuerst die Bestandsaufnahme
In den ersten Tagen wird nichts umgebaut, sondern inventarisiert. Bei Laravel geben schon die Standard-Kommandos schnell ein ehrliches Bild, ohne dass man eine Zeile ändert.
composer outdated zeigt, wie weit die Abhängigkeiten zurückliegen. composer audit meldet bekannte Sicherheitslücken auf Paketebene. Es prüft die installierten Pakete gegen bekannte Advisories, nicht die Sicherheit der Anwendung insgesamt. Ein Blick in die composer.json verrät, welche Pakete überhaupt noch gepflegt werden und welche als abandoned markiert sind. Ein abandoned-Marker ist erstmal ein Warnsignal, kein automatischer Blocker. Manche dieser Pakete sind stabil und unkritisch. Aber genau sie werden beim späteren Upgrade oft zum Problem, dazu kommt ein eigener Artikel. Und die eingesetzte PHP-Version ist oft der stillste Risikofaktor: Läuft eine übernommene Anwendung noch auf PHP 7.4, das seit Ende 2022 aus dem Security-Support ist, dann ist das eine Deadline, keine Geschmacksfrage. Ein typischer Bestandszustand, keine Seltenheit.
Dazu kommen die Stellen, an denen man bei Laravel den Zustand einer gewachsenen App am schnellsten abliest. Wie groß sind routes/web.php und routes/api.php? Hunderte Routen in einer Datei sind ein Hinweis, wo die Struktur nachgegeben hat. Was steckt an eigener Middleware und in den Service-Providern, also im Teil, der bei jedem Request mitläuft? Laufen ein Scheduler und Queue-Worker, und wenn ja, was hängt an ihnen? Das ist oft die Ecke, in der die kritische Fachlogik sitzt, die im Tagesgeschäft niemand sieht. Und schließlich: Wie wird überhaupt deployt? Ein Blick darauf sagt mehr über den Reifegrad als jede Zeile Code.
All das wandert in eine Notizdatei, nichts davon wird angefasst. Am Ende steht ein Inventar, keine aufgeräumte Codebase. Wer hier anfängt zu fixen, verliert den Überblick, bevor er ihn überhaupt hatte.
Erst das Sicherheitsnetz
Bevor irgendetwas am Code passiert, kommt das Netz, das jede spätere Änderung auffängt. Niemand steigt ohne Sicherung aufs Gerüst, und jede Änderung in fremdem Code ist so ein Schritt nach oben. Bei Laravel ist dieses Netz schneller gespannt, als viele denken.
Der erste Schritt ist eine CI, die überhaupt existiert und rot werden kann. Ein Workflow, der die Abhängigkeiten installiert und die Testsuite startet, auch wenn diese Testsuite am Anfang aus einem einzigen Test besteht. Viel braucht es dafür nicht:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2' # an die Produktionsversion anpassen
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test
Das ist ein Skelett, keine schlüsselfertige Laravel-CI. Sobald die Tests eine echte Datenbank brauchen, kommt ein Datenbank-Service dazu, und die Testumgebung muss zur Produktion passen (dazu unten mehr). Für den ersten grünen Lauf reicht es. Genau der eine Test, den php artisan test hier startet, ist der zweite Schritt: ein Smoke-Test, der die wichtigste Route aufruft und prüft, dass sie überhaupt eine Antwort liefert. Kein tiefes Assert, nur „die Seite fällt nicht auf die Nase".
Das klingt nach wenig. Aber eine Pipeline, die rot werden kann, ist der eigentliche Wendepunkt. Ab da hat jede Änderung einen Autor, ein Datum und einen Diff, und nichts geht mehr unbemerkt durch. Auf dieser Grundlage wird alles Weitere überhaupt erst sicher.
Verhalten festschreiben mit Feature-Tests
Sobald die Pipeline steht, kommt das eigentliche Netz: Tests, die das gegenwärtige Verhalten festhalten. Und hier hat Laravel einen fast unfairen Vorteil. Seine Feature-Tests lassen einen kompletten HTTP-Request durch die Anwendung laufen (Routing, Middleware, Controller, Datenbank), ohne dass ich für den ersten Sicherheitszaun verstehen muss, was im Controller im Detail passiert. Ich schicke eine Anfrage rein, schaue mir die Antwort an und schreibe genau diese Antwort als Erwartung fest. Das heißt nicht, dass Domänenwissen überflüssig wird: Um sinnvolle Eingaben zu wählen, Seiteneffekte zu beurteilen und später die richtigen Stellen zu verbessern, brauche ich es weiterhin. Für den beobachtbaren Vertrag des Endpunkts reicht zunächst begrenztes Verständnis, und genau den will ich in dieser Phase absichern.
Das ist Characterization Testing, angewandt auf den HTTP-Layer. Der Test sagt nicht, was fachlich richtig ist. Er hält fest, was die Anwendung heute tut. Wie dieses Vorgehen grundsätzlich funktioniert und wo seine Grenzen liegen, habe ich in einem früheren Logbuch beschrieben. Hier geht es nur um die Laravel-Variante davon.
So sieht ein solcher Test aus. Er ruft einen bestehenden Endpunkt auf und schreibt dessen Antwort fest, ohne dass ich die Logik dahinter kennen muss:
public function test_bestell_endpunkt_haelt_aktuelles_verhalten_fest(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/orders', [
'items' => [['sku' => 'A-100', 'qty' => 2]],
'coupon' => 'WELCOME10',
]);
$response->assertStatus(200);
// Werte aus dem ersten Lauf uebernommen, nicht bewertet:
$response->assertJson([
'status' => 'ok',
'total' => 123.45,
]);
}
Beim ersten Lauf lese ich ab, was der Endpunkt zurückgibt, und trage genau das als Erwartung ein. Der 123.45 oben ist der Platzhalter für diesen abgelesenen Wert. In der Praxis schreibe ich dabei selten nur eine einzelne Zahl fest, sondern die relevante Struktur der Antwort oder einen bewusst gewählten Ausschnitt davon. Was genau schützenswert ist, entscheidet der Fall. Ob dieser Wert fachlich korrekt ist, steht auf einem anderen Blatt. Wenn mir dabei etwas auffällt, das falsch aussieht, kommt es in ein Ticket, aber es wird nicht stillschweigend „mitkorrigiert". Denn ab jetzt bricht jede ungewollte Änderung an diesem Verhalten den Build, und das ist der ganze Punkt.
Ein Wort zum Datenbank-Zustand, weil hier viele Feature-Tests stolpern. Ein Characterization-Test soll Verhalten festhalten, nicht gegen zufällige Produktionsdaten prüfen. Deshalb gehört der Ausgangszustand kontrolliert: definierte Testdaten pro Fall, aufgebaut über Factories, nach dem Test wieder verworfen (mit RefreshDatabase oder einer vergleichbaren Reset-Strategie). Die Aussage verschiebt sich damit von „diese echten Daten liefern jenes Ergebnis" zu „diese definierten Daten liefern jenes Ergebnis". Und genau das will ich, denn nur so ist der Test bei jedem Lauf reproduzierbar.
Wichtig ist außerdem die Auswahl: Ich fange mit den fachlich zentralen, aber überschaubaren Pfaden an, nicht mit der kompliziertesten Stelle der Anwendung. Wer am Anfang die verworrenste Funktion festschreiben will, scheitert an ihren Sonderregeln, bevor das Werkzeug überhaupt sitzt.
Refactoring kommt zuletzt
Wenn das Netz für die wichtigsten Pfade steht, fängt die eigentliche Arbeit an: Fat Controller in Action- oder Service-Klassen auslagern, Geschäftslogik aus den Blade-Views ziehen, Models entschlacken. Jeder dieser Schritte ist ein eigener, kleiner Umbau mit einem grünen Test davor und danach. Wird er nach dem Verschieben einer Methode rot, habe ich Verhalten geändert und weiß es sofort, statt es Monate später von einem Nutzer zu erfahren.
Für größere Umbauten innerhalb einer Klasse ist Branch-by-Abstraction das Mittel der Wahl. Welche Migrationsstrategie wann passt, habe ich an anderer Stelle im Vergleich durchgespielt.
Stolperfallen
Tests gegen SQLite, Production auf MySQL. Der bequemste Weg, eine Laravel-Testsuite schnell laufen zu lassen, ist eine SQLite-In-Memory-Datenbank. Praktisch, aber SQLite und MySQL verhalten sich nicht identisch. JSON-Spalten, Datumsfunktionen, Sortier-Collations, das Verhalten bei ungültigen Werten: Da gibt es Unterschiede, die genau in den Edge-Cases zubeißen, um die es bei einer Übernahme geht. Ein grüner Build gegen SQLite beweist wenig, wenn Production auf MySQL läuft. Ich teste gegen dieselbe Datenbank-Engine wie in Produktion, auch wenn das Setup einen Tick aufwändiger ist.
Schwere Seeder als Testfundament. RefreshDatabase mit einem dicken Seeder, der bei jedem Test die halbe Anwendung befüllt, wird schnell langsam und brüchig. Eine träge Testsuite schaltet irgendwann jemand genervt ab, und dann ist das Netz weg. Besser: schlanke Factories, die pro Test nur das anlegen, was dieser Test wirklich braucht.
Der Sprung auf die neueste Version, bevor ein Test steht. Der verlockendste Fehler. „Wenn wir eh dran sind, ziehen wir Laravel auch gleich hoch." Genau falsch herum. Ohne Testabdeckung ist das Upgrade ein Blindflug, und wenn danach etwas kaputt ist, fehlt der Beweis, woran es lag. Erst das Netz, dann die Version.
Ein Laravel ohne Tests ist kein Sonderfall, sondern einer der häufigsten Übernahmefälle überhaupt. Läuft bei Ihnen so eine Anwendung, die keiner mehr anfassen will, dann fangen Sie nicht beim Upgrade an, sondern beim Sicherheitsnetz. Wenn Sie sich unsicher sind, wo dieses Netz bei Ihrer App ansetzt: Schreiben Sie mir kurz Ihre Ausgangslage, ich melde mich mit einer ersten Einschätzung. Termin buchen.