PHP
Legacy PHP naar Laravel 12: een dual-write cutover
Hoe we in zeven weken het 17 jaar oude PHP 7.0-portaal van een Zwolse distributeur op Laravel 12 zetten, terwijl 28 SOAP-EDI-feeds gewoon bleven antwoorden.

Het kantoor staat in een laag bakstenen pand aan de IJsselallee in Zwolle, vijf minuten rijden van het station. Op de dinsdag dat we binnenliepen, had de IT-verantwoordelijke een uitgeprinte lijst van 312 orderbevestigingen die vastzaten in de outbound EDI-queue, plus een koffie die al een uur koud stond. Het dealer-management-portaal dat het bedrijf sinds 2009 draaide, weigerde netjes te herstarten na de MySQL-patch van de nacht ervoor. PHP-versie 7.0. Database MySQL 5.6. Achtentwintig live EDI-feeds van Claas- en Kubota-dealers stapelden zich rustig op tegen een Apache-proces dat niet wilde herladen.
Dat was het moment waarop de herbouw geen optie meer was.
Hieronder staat de playbook die we gebruikten om dat portaal in zeven weken te verhuizen naar Laravel 12, Postgres 16 en Inertia.js, terwijl de SOAP-endpoints de hele tijd op dezelfde URL's bleven antwoorden. Het is geen generieke Laravel-migratiepost. Het is wat werkte bij een distributeur met 41 medewerkers, nul tolerantie voor downtime tussen 07:00 en 10:00 Amsterdamse tijd, en twee fabrikanten die hun EDI-partners auditen.
De randvoorwaarden waarmee we te maken kregen
PHP 7.0 wordt sinds 10 december 2018 niet meer ondersteund, volgens de eigen pagina met ondersteunde versies van het PHP-project. MySQL 5.6 bereikte zijn end of life in februari 2021. Beide draaiden nog. Geen van beide had in jaren een security-patch gekregen. Het portaal had twee klanten die richting ISO 27001 keken en een verzekeraar die in de volgende verlengingsronde scherpe vragen stelde.
Daarbovenop:
- 28 actieve EDI-feeds, elk op een vaste SOAP-endpoint-URL die de fabrikanten in hun dealer-systemen hadden ingebakken.
- 9 dagelijkse power-users in operations en onderdelen, plus zo'n 30 occasionele logins vanuit sales en service.
- Een ochtendvenster van 07:00 tot 10:00 waarin voorraadtoewijzingen moesten worden afgerond, en waarin een storing van vijf minuten zich vertaalde naar een stapel boze telefoontjes van echte dealers in real time.
- 47 MySQL stored procedures, 11 views, 3 cron-bestanden die elkaar aanriepen in een afhankelijkheidsketen die niemand sinds 2014 had uitgetekend.
De klant wilde Laravel omdat hun dichtstbijzijnde junior developer het al kende. Ze wilden Inertia omdat ze een schermopname hadden gezien en de snelheid waardeerden. Wij waren het met beide eens. Postgres voegden we toe omdat Postgres 16 JSON, partial indexes en strengere typering afhandelt op een manier die schoon aansluit op het soort code dat Laravel uitlokt.
Het systeem lezen voor we iets aanraakten
De eerste twee weken waren read-only. We schreven geen Laravel-code. We draaiden geen migratie. We brachten in kaart wat er stond.
Het resultaat van die twee weken was één Markdown-bestand met zeven secties: SOAP-methodes (89 in totaal verdeeld over twee WSDL's), database-objecten (tables, views, procedures, triggers), cron-entries (14 verspreid over twee servers), shell-scripts die de database raakten, third-party integraties (een Duitse vracht-API en een Nederlandse boekhoudexport), background workers (geen, het was allemaal cron), en een lijst van elke plek waar de codebase een datum wegschreef in een ander formaat dan ISO 8601.
Die laatste woog zwaarder dan het klinkt. De helft van de SOAP-responses gaf datums uit als d-m-Y. Twee van de EDI-partners parsten die strikt. Elke herschrijving moest die opmaak aan response-zijde op byte-niveau bewaren, ook al sloegen we intern alles op als timestamptz.
Als je de lelijke delen van het legacy systeem niet documenteert voor je een regel nieuwe code schrijft, gaat het nieuwe systeem elke oude fout opnieuw uitvinden die het niet kende. Twee weken lezen is goedkoop.
De dual-write-architectuur van zeven weken
Een big-bang cutover was geen optie. Niet uit zenuwen, maar vanwege audit trails. De boekhoudexport had een ononderbroken genummerde reeks nodig op uitgaande facturen. Twee van de EDI-partners eisten ondertekende wekelijkse reconciliatierapporten. Elke cut waarbij het nieuwe systeem kort niet overeenkwam met het oude, zou een handmatige reconciliatie afdwingen die geen van beide partijen wilde doen.
Dus bouwden we dual-write. Beide systemen draaien. Beide accepteren writes. Het legacy PHP-systeem blijft tot het einde toe leidend voor reads en uitgaande SOAP-responses. Het nieuwe Laravel-systeem schaduwt elke write, draait elke business rule onafhankelijk, en brengt elke drift naar boven in een dagelijks diff-rapport.
Het plaatje, versimpeld:
+-------------------+
HTTP / SOAP | nginx (router) |
from dealers +---------+---------+
|
+-------------------+-------------------+
| |
+---------------+ +----------------+
| Legacy PHP | dual-write events | Laravel 12 |
| + MySQL 5.6 |---------------------->| + Postgres 16 |
| (authoritative| | (shadow, then |
| until week 7)| | authoritative)|
+---------------+ +----------------+
Het dual-write-kanaal was bewust saai. Een kleine PHP-class in de legacy codebase duwde JSON-payloads op een Redis stream na elke geslaagde transactie. Een Laravel queue worker consumeerde de stream, speelde de write opnieuw af via Eloquent, en schreef een rij naar een sync_events-tabel met het resultaat. Een nachtelijke job vergeleek row counts en key-column hashes tussen MySQL en Postgres en schreef een samenvatting van één regel naar een Slack-kanaal dat de IT-verantwoordelijke van de klant elke ochtend bij de koffie las.
De SOAP-endpoints aan de praat houden
Dit was het deel waar mensen wakker van lagen. Dealer-systemen bij Claas en Kubota gingen hun endpoint-URL's niet aanpassen omdat een Nederlandse distributeur een portaal aan het herbouwen was. De endpoints moesten live blijven, op dezelfde hostnames, met dezelfde WSDL's, byte-voor-byte gelijk waar de partners erom gaven.
We deden drie dingen.
Eerst hebben we de SOAP-laag uit de legacy codebase gehaald en in een dunne shim ervoor gezet. nginx routeerde /soap/* naar een aparte PHP-FPM pool. Dat gaf ons een schone grens om later te swappen, zonder de rest van de legacy code aan te raken.
Daarna bouwden we een parallelle SOAP-server in Laravel met PHP's ingebouwde SoapServer-class, gekoppeld aan een controller. Laravel afficheert zichzelf niet als een SOAP-framework, en dat is prima, want PHP zelf levert er een. Het minimum ziet er zo uit:
// routes/soap.php
Route::any('/soap/dealer/v1', function (Request $request) {
$wsdl = storage_path('soap/dealer-v1.wsdl');
$server = new \SoapServer($wsdl, [
'cache_wsdl' => WSDL_CACHE_NONE,
'features' => SOAP_SINGLE_ELEMENT_ARRAYS,
]);
$server->setObject(app(DealerSoapHandler::class));
ob_start();
$server->handle($request->getContent());
$response = ob_get_clean();
return response($response, 200)
->header('Content-Type', 'text/xml; charset=utf-8');
});
Als derde lieten we de legacy SOAP-server en de Laravel SOAP-server drie weken naast elkaar draaien. Elk inkomend verzoek werd beantwoord door de legacy server. Dezelfde payload werd asynchroon doorgestuurd naar de Laravel-server, waarvan de response werd vergeleken met de legacy response en gelogd. Tegen week vijf was de diff leeg voor 27 van de 28 feeds. De 28e was een Kubota-partner wiens systeem een misvormde namespace-declaratie meestuurde die de legacy code stilzwijgend negeerde. We leerden Laravel om dezelfde misvorming te negeren, legden het vast, en gingen door.
Een herschrijving die een publieke API moet behouden is geen herschrijving. Het is een re-implementatie achter een bevroren contract. Behandel het contract als een test suite, niet als specificatie.
MySQL 5.6 verhuizen naar Postgres 16
De schemaverhuizing kende drie categorieën werk. Mechanisch, semantisch en procedureel.
Mechanisch was het goedkoopst. pgloader deed het grootste deel van de tabelverhuizingen in één run, met een configuratiebestand dat TINYINT(1) vertaalde naar boolean, MySQL-ENUM-kolommen naar Postgres CHECK-constraints, en de verschillende 0000-00-00-sentineldatums naar NULL. Alleen die laatste vertaling raakte 18.400 rijen in de orderhistorie.
Semantisch was lastiger. MySQL 5.6 met de standaard SQL-mode tolereerde veel. Impliciete string-naar-integer-casts, datums die niet bestonden, group-by-kolommen die niet in de select stonden. Postgres tolereert niets daarvan. Vanaf week twee draaiden we de testsuite in CI tegen Postgres, waardoor een lange staart aan kleine bugs zichtbaar werd. De meeste waren one-liners. Een paar legden echte logische fouten bloot die in het legacy systeem jarenlang stilletjes verkeerde totalen hadden geproduceerd.
Procedureel was het duurst. De 47 stored procedures kwamen uit de database en vonden hun weg naar Laravel als service classes, één per domeinconcept (dealer-onboarding, voorraadtoewijzing, EDI-verzending, factuurnummering, enzovoorts). Elk kreeg tests. Een paar bleken, toen ze voor het eerst sinds 2011 bij daglicht werden gelezen, niets te doen wat de applicatiecode elders niet al twee keer deed. Die hebben we verwijderd.
Het schema van zeven weken
De agenda was strak maar niet heroïsch.
- Week 1. Het systeem lezen. Het Markdown-bestand opleveren. De lege Laravel-app neerzetten achter een wildcard-subdomein zonder verkeer.
- Week 2. Schemavertaling met pgloader. CI groen tegen Postgres. Authenticatie geport.
- Week 3. Dual-write aan voor de drie laagrisico-tabellen (dealers, producten, contacten). Dagelijks drift-rapport naar Slack.
- Week 4. Dual-write uitgebreid naar orders, toewijzingen en de factuurreeks. Inertia-gedreven schermen in het medewerkersportaal vervangen de oudste legacy pagina's.
- Week 5. Laravel SOAP-server opgetuigd. Shadow-mode response-diffing op elke inkomende EDI-call. Drift gaat van 38 procent naar onder 1 procent.
- Week 6. Read-verkeer voor het medewerkersportaal verschuift naar Laravel. Het legacy PHP stopt met HTML serveren. SOAP staat nog op legacy.
- Week 7. nginx zet de SOAP-route om naar Laravel tijdens het zaterdagse onderhoudsvenster. Dual-write keert 72 uur om als verzekering. De legacy MySQL gaat op de dinsdag erna in read-only.
Het rollback-plan dat je hoopt nooit te draaien
Een cutover zonder rollback is geen cutover, het is een sprong. We schreven de rollback als eerste en testten 'm twee keer voor we productie aanraakten.
Drie componenten. nginx had één map-directive waarvan de waarde wisselde tussen legacy en laravel, dus verkeer terugzetten was één config-reload. De dual-write stream bleef 72 uur na de cutover in reverse-flow staan, zodat Laravel-writes terug werden gespiegeld naar MySQL en het warm hielden. DNS-TTL's op de SOAP-hostnames waren een volle week voor het venster verlaagd naar 60 seconden. De hele rollback was één shell-script met een bevestigingsprompt, in beheer bij de IT-verantwoordelijke van de klant. We zorgden ervoor dat hij het op de donderdag voor de cutover één keer in staging draaide, gewoon om de toetsen onder zijn vingers te voelen.
We hebben hem niet nodig gehad. Dat is de enige reden om hem te schrijven.
Wat we anders zouden doen
Met de kennis van nu, twee dingen.
We besteedden in week 4 te veel tijd aan de UI van het medewerkersportaal. Inertia.js maakt het verleidelijk om tijdens de migratie meteen door te ontwerpen, en dat deden we. De klant was verrukt van de nieuwe schermen, maar we verloren drie dagen die naar de SOAP-shim hadden moeten gaan. Als we dit opnieuw zouden draaien, was de nieuwe UI een aparte fase ná de cutover, niet tijdens.
En we onderschatten hoeveel waarde het dagelijkse drift-rapport zou opleveren als permanent artefact. Zes maanden na go-live leest de klant het nog elke ochtend. Het vangt bugs in de boekhoudexport voordat de boekhouder dat doet. Een klein Slack-gebonden scriptje, in week drie geschreven om onszelf eerlijk te houden tijdens de migratie, is rustig onderdeel geworden van hoe het bedrijf draait. Vanaf nu bouwen we er in elke migratie eentje in.
Toen we het dealer-portaal voor deze Zwolse distributeur herbouwden, was wat we tegenkwamen de byte-niveau-fragiliteit van de inkomende EDI-contracten. We losten het op met een drie weken durende shadow-diff tussen de legacy en de Laravel SOAP-servers, waarbij we de feitelijke payloads van de partners behandelden als de enige spec die telde. Dat soort legacy-stack-migratie is het meeste van wat we doen voor distributeurs en operators met een systeem dat oud genoeg is om te stemmen.
Zit je op een PHP 7-portaal met live integraties en een agenda zonder downtime-venster, dan is de goedkoopste vervolgstap die leesronde van twee weken. Breng je SOAP-methodes, je stored procedures en je datumformaten in kaart voordat je een framework kiest. Daarna wordt de herschrijving makkelijker.
Kern
Een herschrijving die een publieke API moet behouden is geen herschrijving. Het is een re-implementatie achter een bevroren contract. Behandel het contract als een test suite, niet als specificatie.
FAQ
Waarom dual-write in plaats van een big-bang cutover in één nacht?
Omdat het portaal een ononderbroken factuurreeks, ondertekende EDI-reconciliatierapporten en een ochtendvenster van drie uur kende met nul tolerantie voor uitval. Met dual-write konden we wekenlang pariteit aantonen voor we live verkeer aanraakten.
Waarom Postgres als het team MySQL al kende?
De strengere typering, JSON-ondersteuning en partial indexes van Postgres 16 sluiten schoner aan op Eloquent en de migrations van Laravel dan MySQL 5.6, en CI tegen Postgres bracht jarenoude logische bugs aan het licht die de soepele MySQL-modus had verstopt.
Kan Laravel echt SOAP-endpoints hosten voor EDI-partners?
Ja. PHP levert SoapServer in core. Koppel hem aan een Laravel-route, wijs hem naar de bestaande WSDL, en shadow-diff elke response tegen de legacy server tot de drift nul is. Het framework hoeft niet te weten dat het SOAP is.
Hoe lang was de daadwerkelijke downtime tijdens de cutover?
Ongeveer vier minuten tijdens een zaterdags onderhoudsvenster voor de nginx-route-flip op SOAP-verkeer. Het read-verkeer van het medewerkersportaal verhuisde in week 6 zonder merkbare downtime, achter een feature flag.