Technische Case Study · B2B-Plattform eines Automobilherstellers
Von Laravel 8 auf 13: Wie ich eine gewachsene B2B-Plattform modernisiert habe.
Der Auftrag begann als Serverumzug. Bei der Bestandsaufnahme wurde jedoch klar: Würde ich die Laravel-8-Anwendung unverändert verschieben, läge dieselbe Wartungsaufgabe anschließend nur auf einem neuen Host.
- Laravel 8 → 13
- Framework-Pfad
- 698
- PHP-Tests
- 4.593
- Assertions
- 35
- Fokussierte Commits
Bestandsaufnahme
Der Serverumzug legte die eigentliche Aufgabe frei
Die Anwendung lief auf Laravel 8, PHP 8.0, Livewire 2, Jetstream 2, PHPUnit 9, Tailwind 2, Alpine 2 und Laravel Mix. Über Jahre waren dort fachliche Regeln für Bestellungen, Berechtigungen, Lagerbewegungen, Dokumente und externe Übergaben gewachsen.
Das Zielsystem läuft mit Laravel 13, PHP 8.3, Livewire 4, Jetstream 5, PHPUnit 12, Tailwind 4 und Vite 8. Zwischen diesen beiden Tabellenzeilen lagen keine einzelne Composer-Aktualisierung, sondern 35 kleine, überprüfbare Commits.
| Stand | Backend | UI | Build / Tests |
|---|---|---|---|
| Ausgang | Laravel 8 · PHP 8.0 | Livewire 2 · Tailwind 2 | Mix · PHPUnit 9 |
| Heute | Laravel 13 · PHP 8.3 | Livewire 4 · Tailwind 4 | Vite 8 · PHPUnit 12 |
Verhalten absichern
Die Tests kamen vor dem ersten Versionssprung
Ich rekonstruierte zuerst das Domänenmodell aus Routen, Livewire-Komponenten, Jobs, Migrationen und Produktionsabläufen. Der große Baseline-Commit fügte 10.423 Zeilen für Tests, Factories und unterstützenden Code hinzu. Er reparierte außerdem bereits vorhandene Fehler, solange die Anwendung noch auf Laravel 8 lief.
Ein Schutzmechanismus erwies sich sofort als wichtig: Die Suite verweigert jeden Start, wenn die konfigurierte Datenbank nicht mit test_ beginnt. Als gecachte Konfiguration auf eine falsche Datenbank zeigte, stoppte sie vor RefreshDatabase und damit vor destruktiven Operationen.
protected function setUpTraits()
{
$database = $this->configuredDatabaseName();
if (! str_starts_with($database, 'test_')) {
throw new RuntimeException(
"Refusing destructive tests for database [{$database}]."
);
}
return parent::setUpTraits();
}
Livewire 2 → 3 → 4
Rendern allein beweist keinen funktionierenden Workflow
Livewire war riskanter als viele Laravel-Schritte. Ein Render-Test findet weder geänderte Hydration-Regeln noch Fehler beim Speichern. Die Baselines mounten deshalb echte Workflows, verändern öffentlichen Zustand, führen die Aktion aus und prüfen Redirect, Persistenz und Berechtigung.
Livewire::actingAs($user)
->test(AccountProfile::class)
->assertSet('primaryAccountId', $first->id)
->set('primaryAccountId', $second->id)
->call('savePrimaryAccount')
->assertHasNoErrors()
->assertRedirect('/profile');
$this->assertDatabaseHas('account_user', [
'user_id' => $user->id,
'account_id' => $second->id,
]);
Diese Form blieb über Livewire 2, 3 und 4 gleich nützlich: Die interne Implementierung durfte sich ändern, der beobachtbare Vertrag nicht.
Abhängigkeiten
Den kleinen Vertrag erhalten, nicht das alte Paket
Ein nicht mehr gepflegtes Warenkorb-Paket blockierte neuere Framework-Versionen. Statt dessen gesamten Code zu kopieren oder gleichzeitig alle Checkout-Flows umzuschreiben, übernahm die Anwendung nur den kleinen Vertrag, den sie tatsächlich benutzt: Instanzen, Buyables, Optionen, Events, Summen und gespeicherte Warenkörbe.
$first = Cart::instance('default')->add($article, 2, $options);
$second = Cart::instance('default')->add($article, 3, $options);
$this->assertSame($first->rowId, $second->rowId);
$this->assertSame(5, $second->qty);
$this->assertSame('6000.00', Cart::total(2, '.', ''));
So blieb die Migration klein. Die Kompatibilität ist heute durch Anwendungstests dokumentiert und nicht mehr von einem Paket abhängig, dessen Release-Zyklus außerhalb meiner Kontrolle liegt.
Vertrauensgrenzen
Öffentliche Component-Properties sind Benutzereingaben
Livewire synchronisiert Zustand zwischen Browser und Server. Deshalb behandelte ich Preis, Menge und Datensatz-IDs in öffentlichen Properties wie Request-Daten. Der Server berechnet geschützte Werte neu und prüft die Zugehörigkeit beim Speichern erneut — nicht nur beim Mounten der Komponente.
Livewire::actingAs($owner)
->test(EditOrder::class, ['order' => $order])
->set("lines.{$line->id}.price", 1)
->set("lines.{$line->id}.quantity", 999)
->call('save')
->assertDispatched('saved');
$this->assertSame(1000, $line->fresh()->price);
$this->assertSame(5, $line->fresh()->quantity);
Ein zweiter Test entfernt die Mitgliedschaft nach dem Mount und erwartet beim Speichern 403. Damit schützt die Suite ausdrücklich gegen Berechtigungen, die zwischen zwei Requests widerrufen wurden.
Wiederholbare Abläufe
Doppelklicks und Worker-Restarts mitdenken
Checkout-Tokens verhindern doppelte Bestellungen bei wiederholten Requests. Für Gutschriften führt ein persistenter Outbox-Datensatz getrennt Buch darüber, ob Dokument, Archiv und E-Mail bereits erledigt sind. Der Job ist eindeutig, blockiert parallele Ausführung und reserviert seine Arbeit in einer Transaktion.
final class DeliverCredit implements ShouldQueue, ShouldBeUnique
{
public function middleware(): array
{
return [(new WithoutOverlapping($this->deliveryId))->releaseAfter(30)];
}
public function handle(): void
{
$delivery = DB::transaction(fn () => $this->claimForUpdate());
$this->deliverMissingSteps($delivery);
}
}
Die Tests erzwingen außerdem, dass eine bereits reservierte Rechnungsnummer bei einem Retry nicht weiterläuft. Das ist der Unterschied zwischen „der Job läuft meistens“ und einem nachvollziehbaren Wiederholungsmodell.
Tailwind und Vite
Der Produktions-Build braucht einen eigenen Vertrag
Laravel Mix wurde durch Vite ersetzt, Tailwind ging zunächst auf 3 und danach auf 4. Dabei können dynamisch zusammengesetzte Klassen aus dem finalen CSS verschwinden, obwohl sämtliche PHP-Tests grün sind. Ein kleines Node-Skript liest deshalb das echte Vite-Manifest und prüft 22 notwendige Selektoren in der erzeugten Datei.
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const cssEntry = manifest['resources/css/app.css'];
const css = readFileSync(resolve(buildPath, cssEntry.file), 'utf8');
const missing = requiredSelectors
.filter(([, selector]) => ! css.includes(selector));
if (missing.length) throw new Error(formatMissing(missing));
Lokale Icons
Weniger Laufzeitmagie, weniger Abhängigkeiten
Zum Abschluss ersetzte ich 124 Verwendungen externer Icon-Komponenten durch lokale Blade-Komponenten. 52 SVGs kamen lokal hinzu; zusammen mit dem vorhandenen Custom-Icon sind es 53. Dadurch konnten 17 Composer-Pakete entfernt werden.
$html = Blade::render(sprintf(
'<x-icons.%s class="icon-test" aria-label="%s" />',
$icon,
$icon,
));
$this->assertStringContainsString('<svg', $html);
$this->assertStringContainsString('class="icon-test"', $html);
$this->assertStringContainsString('aria-label=', $html);
Der Test rendert jede lokale Komponente, prüft weitergereichte HTML-Attribute und verbietet die alten Component-Präfixe in den Views. Damit wird aus einer Aufräumaktion ein dauerhaft überprüfter Deployment-Vertrag.
Produktionsmigration
Staging, Mailpit und kontrollierter Cutover
Das Upgrade bekam eine isolierte Staging-Umgebung auf dem neuen Runtime-Stack. Anonymisierte Daten machten die fachlichen Abläufe realistisch prüfbar; Mailpit fing jede ausgehende Nachricht ab. Scheduler, Queue, öffentliche Einstiegspunkte, sechs Browser-Flows, Blade-Kompilierung, Composer-Audit und der Produktions-Build liefen vor dem Cutover durch.
Nach der Kundenabnahme folgten Daten-Freeze, finale Datenbankmigration und Domain-Umschaltung. Anschließend prüfte ich Anmeldung, Bestellablauf, Dokumente, Mail-Übergaben, Queue und Scheduler erneut auf dem neuen Host. Die aktualisierte Anwendung läuft dort jetzt mit Laravel 13.
Rückblick
Was ich beim nächsten Upgrade wieder so machen würde
Ich würde wieder mit dem Verhalten beginnen, nicht mit Versionsnummern. Zuerst die fachlichen Pfade und destruktiven Testgrenzen absichern, dann eine Hauptversion nach der anderen bewegen und nach jedem Schritt zum vollständig grünen Zustand zurückkehren.
Laravel selbst war selten der schwierigste Teil. Die meiste Urteilskraft brauchten Livewire-Zustand, aufgegebene Pakete, der CSS-Produktionsvertrag, wiederholbare Hintergrundarbeit und der letzte Weg in Produktion. Genau dort zahlen anwendungseigene Verträge und kleine, nachvollziehbare Commits aus.
Autor und technische Grundlage
Wer diese Modernisierung umgesetzt hat
Ich bin Mathias Onea, Software Engineer mit Schwerpunkt Laravel, PHP und gewachsenen Geschäftsanwendungen. In diesem Projekt habe ich die Bestandsaufnahme, Upgrade-Strategie, Testarchitektur, Security-Arbeit und Frontend-Migration umgesetzt und den Wechsel auf den neuen Server begleitet.
Die Beispiele auf dieser Seite stammen aus den Tests, Jobs, Migrationen und Build-Skripten der abgeschlossenen Anwendung. Zahlen und Versionsstände wurden gegen den finalen Upgrade-Branch und den erfolgreichen CI-Lauf geprüft.
- Direkte Projekterfahrung
- 35 fokussierte Commits vom Laravel-8-Baseline-Schutz bis zum finalen Laravel-13- und Icon-Cleanup.
- Technisch verifiziert
- 698 PHP-Tests, 4.593 Assertions, Produktions-Build, CSS-Vertrag und sechs Browser-Flows bestanden.
- Stand der Prüfung
- Code, Abhängigkeiten und Projektergebnisse zuletzt am 16. Juli 2026 geprüft.