Technische Case Study · B2B-Plattform eines Automobilherstellers

Artikel Von

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 auf 13 Modernisierung und Servermigration
Der Ausgangspunkt des Projekts: Ein geplanter Serverumzug wurde zu einer vollständigen Laravel-Modernisierung.
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.

StandBackendUIBuild / Tests
AusgangLaravel 8 · PHP 8.0Livewire 2 · Tailwind 2Mix · PHPUnit 9
HeuteLaravel 13 · PHP 8.3Livewire 4 · Tailwind 4Vite 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.

PHP· Fail-closed TestCase
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.

PHP· Verhaltensbaseline einer Profiländerung
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.

PHP· Kompatibilitätsvertrag des lokalen Warenkorbs
$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.

PHP· Manipulierte Werte bleiben ohne Wirkung
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.

PHP· Durable Delivery Job
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.

JavaScript· Vertrag für das gebaute CSS
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.

PHP· Regressionstest für alle lokalen SVGs
$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.

FAQ

Fragen zur Laravel-Modernisierung

Wann ist ein Upgrade besser als ein Rewrite?

Wenn der fachliche Kern weiterhin stimmt und viel implizite Geschäftslogik im System steckt. Eine belastbare Testbasis macht den schrittweisen Weg messbar.

Warum jede Laravel-Hauptversion einzeln?

Weil sich Fehler dann einem kleinen Änderungssatz zuordnen lassen. Nach jeder Stufe gingen Tests, Build und Browser-Flows wieder auf Grün.

Reichen PHPUnit-Tests für Livewire und Tailwind?

Nein. Livewire braucht Workflow- und Autorisierungstests; Tailwind und Vite brauchen Prüfungen gegen die tatsächlich erzeugten Produktionsdateien.

Wie wird ein Serverwechsel in so einem Projekt abgesichert?

Mit isoliertem Staging, anonymisierten Daten, abgefangenen E-Mails, reproduzierbaren Builds, Browser-Smoke-Tests und einem geplanten Daten-Freeze.

Gewachsene Laravel-Anwendung?

Den sicheren Upgrade-Pfad abstecken.

Wenn Sie eine erste technische Einschätzung möchten, reichen Laravel-Version, kritische Abläufe und der aktuelle Deployment-Weg.

Laravel-Modernisierung besprechen