— Artikel — № 094

094 —Migration

Laravel-migratie zonder downtime: booking engine herbouwd

Een 14 jaar oude custom-PHP booking engine, een agenda die elke 90 seconden een reservering verwerkte, en een Laravel-herbouw zonder één gemiste boeking.

Bovenaanzicht stilleven met inkttekening migratieplan, Gantt-strook, manilamap, messing plaat, lakzegel op linnen.
Hero · gestileerd stilleven№ 094

De Loom kwam binnen op woensdagavond om 23:41. Een Nederlands bureau waar we mee werken had een spoedklus aangenomen, en de briefing was strak: een 14 jaar oude custom-PHP booking engine, in 2012 geschreven door een ontwikkelaar die inmiddels naar Berlijn was verhuisd en niet meer op e-mail reageerde. De eindklant wilde Laravel. De agenda mocht niet langer dan 60 seconden offline. PHP 8.2 end-of-life was nog 11 maanden weg.

De opname liet de codebase zien. booking.php met 4.800 regels. Eén functions.php met 11.000 regels. Een config.php met de databasecredentials in plain text, drie mappen boven de docroot. De session store was een MySQL-tabel genaamd sessions_NEW2 (de NEW2 stamde uit 2017). De boekingsagenda werd gerenderd door een switch-statement van 600 regels.

Hieronder hoe we het in acht weken dual-running op Laravel 11 kregen, zonder één reservering te verliezen, en wat we anders zouden doen als we de Laravel-migratie opnieuw moesten draaien.

Wat we aantroffen

Voor elke Laravel-migratie brengen we eerst het oppervlak in kaart. Niet in een Confluence-doc, maar in één tekstbestand op het bureaublad van de lead developer, omdat de codebase sneller bewoog dan zo'n doc zou bijhouden.

De engine had drie lagen, geen daarvan schoon:

  • Een publieke booking widget. Eén PHP-bestand, jQuery 1.7, een knoop van inline CSS.
  • Een beheerderspaneel op /beheer/ achter HTTP Basic auth, met een eigen session handler en een eigen kopie van de agendacode.
  • Een REST-achtig endpoint op /api/v2.php dat door drie Android-tablets aan de balie werd gebruikt. Op de tablets draaide een WebView-app die voor het laatst in 2018 was bijgewerkt. Niemand had de source.

Het MySQL-schema bestond uit 47 tabellen. Zestien daarvan werden niet gebruikt. Zes bevatten booking-data: bookings, booking_lines, booking_slots, booking_holds, booking_history, en een tabel genaamd booking_temp die dragend bleek te zijn. Verwijderen brak de seat-hold-logica, omdat een stored procedure uit 2015 hem als scratch space gebruikte tijdens collision checks.

De eerste beslissing was het schema met rust te laten voor de eerste cutover. Laravel zou lezen en schrijven naar dezelfde tabellen die de legacy code aansprak. Schemawijzigingen kwamen pas nadat de herbouw stabiel draaide.

De randvoorwaarde die niet boog

De agenda draaide 24/7. Reserveringen kwamen op elk uur binnen via drie kanalen. De harde grens van de klant was een venster van 60 seconden voor de cutover zelf, en nul dataverlies over de hele Laravel-migratie. Geen "we zijn het laatste uur kwijt, boek even opnieuw". Hun supportteam bestond uit vier mensen en die zaten al vol.

Dat sloot de schoonste aanpak uit (exporteren, transformeren, importeren, DNS omzwaaien, hopen). We hadden beide stacks nodig die naast elkaar draaiden, naar dezelfde data schreven, totdat we konden bewijzen dat de nieuwe onder live load klopte.

Het patroon is bekend: de strangler fig. De framing van Martin Fowler is degene waar we steeds op terugkwamen. Route een dun stroompje verkeer naar de nieuwe stack. Kijk ernaar. Stuur meer. Uiteindelijk is de oude codebase niets meer dan dode takken die je kunt snoeien.

De twist bij deze engine was dat de naad niet in HTTP zat, maar in de database. De booking widget, het beheerderspaneel en de tablet-API schreven allemaal naar dezelfde zes tabellen. Als we de writes splitsten, splitsten we de waarheid. Dus hielden we per rij één writer tegelijk, en gebruikten MySQL om beide stacks in sync te houden tot we klaar waren om om te schakelen.

De naad routeren in nginx

We zetten nginx voor de bestaande Apache. De legacy code bleef op Apache, Laravel draaide op PHP-FPM 8.3 achter nginx op een tweede machine. De routing-regel voor de Laravel-migratie was één map-block:

map $request_uri $backend {
    default            apache_legacy;
    ~^/api/v3/         laravel_new;
    ~^/beheer/v2/      laravel_new;
    ~^/widget/v2/      laravel_new;
}

server {
    listen 443 ssl;
    server_name booking.example.nl;

    location / {
        proxy_pass http://$backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
}

Nieuwe endpoints leefden onder versioned prefixes. Oude endpoints behielden hun paden. De tablet-API was het lastigste geval, omdat de WebView-clients ingebakken URLs hadden die we niet konden wijzigen. Dat losten we op door een Laravel-route te schrijven die het oude pad exact matchte, met een kleine shim die het oude payload-formaat (XML, snake_case-velden, een MD5-token in plaats van een API-key) vertaalde naar een moderne request voordat de controller hem zag.

Dual writes via MySQL-triggers

De eerste zes weken moest elke boeking die door de legacy code werd geschreven verschijnen in het Laravel-leesbare formaat, en elke boeking die door Laravel werd geschreven in het legacy-formaat. We vertrouwden dual writes op applicatieniveau niet. Te veel plekken om te vergeten.

In plaats daarvan gebruikten we MySQL-triggers. Het legacy-schema bleef leidend. Laravel schreef naar een parallelle set tabellen (bookings_v2, enzovoort). Een trigger op elke tabel spiegelde de rij naar de andere kant.

DELIMITER $

CREATE TRIGGER bookings_to_v2
AFTER INSERT ON bookings
FOR EACH ROW
BEGIN
    INSERT INTO bookings_v2 (
        id, reference, customer_id, slot_id,
        status, total_cents, created_at, source
    ) VALUES (
        NEW.id, NEW.reference, NEW.customer_id, NEW.slot_id,
        NEW.status, ROUND(NEW.total * 100), NEW.created_at, 'legacy'
    )
    ON DUPLICATE KEY UPDATE
        status = VALUES(status),
        total_cents = VALUES(total_cents);
END$

DELIMITER ;

De ON DUPLICATE KEY UPDATE maakte de trigger idempotent. Als Laravel eerst schreef en de legacy code als tweede (wat in het overlap-venster gebeurde), werd de rij verzoend in plaats van gedupliceerd. De MySQL-triggerdocs hebben de volledige grammatica. Het addertje dat we tegenkwamen: triggers vuren niet zoals je zou verwachten op REPLACE INTO-statements, en het legacy beheerderspaneel gebruikte REPLACE op twee plekken. Die twee queries patchten we naar INSERT … ON DUPLICATE KEY UPDATE, daarna verifieerden we dat de trigger bij beide afging.

Het botsingsprobleem

De seat-hold-logica was het riskantste stuk van de Laravel-migratie. Twee klanten die op dezelfde seconde op hetzelfde tijdslot landden, moesten één succes en één "sorry, net verkocht" te zien krijgen. De legacy code gebruikte een stored procedure met een row-level lock op booking_temp. Laravel wist niets van die procedure af.

Voor de overlap-periode probeerden we niet slim te zijn. Beide stacks riepen dezelfde stored procedure aan. De reserveringscontroller van Laravel eindigde met een rauwe DB::statement('CALL hold_slot(?, ?, ?)'). Lelijk, maar correct. De procedure bleef leidend tot de cutover, en daarna herschreven we hem als een Laravel-transactie met SELECT … FOR UPDATE en stuurden hem met pensioen.

In deze fase vingen we één collision-bug. Een race condition waarbij de tablet-API en de publieke widget hetzelfde slot 800ms vasthielden voordat één werd afgewezen. Die zat al jaren latent in de legacy code. De lead developer van het bureau zag het omdat Laravels structured logs het naar boven brachten. De legacy code overschreef de verliezer stilzwijgend met een lege rij, en de foutmelding richting de klant luidde "er ging iets mis", wat minstens een jaar als ruis was genegeerd.

Cutover om 03:47

De daadwerkelijke omschakeling vond plaats op dinsdagochtend. Het rustigste venster voor deze klant was dinsdag tussen 03:30 en 04:30 Amsterdamse tijd. Gemiddeld twee boekingen in dat uur.

De volgorde bestond uit vier stappen en duurde 38 seconden:

  1. Een feature flag in de legacy code omzetten die de booking widget in read-only mode bracht (agenda zichtbaar, formulierknop uit, banner met "Even geduld, we werken aan iets moois").
  2. Wachten tot de lopende requests waren uitgevloeid. We keken in één terminal naar SHOW PROCESSLIST en in een andere naar de nginx access log. Acht seconden om volledig te draineren.
  3. Het nginx map-block updaten zodat de default laravel_new werd in plaats van apache_legacy. Nginx herladen met nginx -s reload. Nul gedropte connecties, omdat nginx netjes hot-reloadt.
  4. De read-only banner verwijderen in Laravels config en de cache leegmaken.

Totale downtime aan de gebruikerskant: 38 seconden, allemaal tijdens het draineren. Er werd geen enkele boeking in dat venster geprobeerd. We hadden een rollback-script klaar dat het map-block terugzette en de legacy code weer aanzette, maar dat hebben we nooit gedraaid.

De eerste boeking via de nieuwe stack kwam binnen om 04:11. De tablet aan de balie stond stand-by, en de eerste walk-in van de ochtendploeg boekte een slot van 09:00. Hij schreef naar bookings_v2, en de trigger spiegelde hem terug naar bookings zodat het legacy beheerderspaneel (dat we nog niet hadden gemigreerd) hem nog steeds zag. We lieten de trigger nog vijf weken draaien en stopten hem pas nadat de herbouw van het beheerderspaneel live ging en de Laravel-migratie was afgerond.

Wat we anders zouden doen

Drie dingen.

Eén: triggertests schrijven vóór de triggers. We testten het spiegelen met de hand en misten in de eerste ronde het REPLACE INTO-geval. Een PHP-script van 30 regels dat insert, update, replace en delete uitvoert op beide tabellen en gelijkheid asserteert, had het in vijf minuten opgepikt.

Twee: agressiever snapshotten. We maakten een database dump aan het begin van elke week van de overlap-periode. Dat was goed. Wat we niet deden, was de Apache-document-root snapshotten, en daar betaalden we voor toen een junior dev aan de bureaukant midden in de migratie een bestand in de legacy code hernoemde om "op te ruimen". De hernoeming brak de bevestigingsmail van de boeking zes uur lang. Een dagelijkse tar -czf van de docroot had de rollback triviaal gemaakt.

Drie: de overlap inkorten. Zes weken dual-writing was te lang. Het bureau lette de eerste twee weken op, deed de volgende twee op de automatische piloot en skimde de laatste twee. De bugs die we in week zes vonden, hadden we in week twee moeten zoeken. Vier weken was genoeg geweest.

De kleinste versie hiervan

Sta je voor een vergelijkbare Laravel-migratie, dan is de eerste stap niet code schrijven. Het is één MySQL-trigger op je belangrijkste tabel zetten en een week kijken wat je applicatie ermee doet. Geen mirror-trigger. Gewoon een audit-trigger die elke INSERT, UPDATE en DELETE logt met de connection ID en het oorspronkelijke statement. Je leert dingen over je codebase. Pas dan plan je de migratie.

Toen we Pier bouwden, liepen we precies tegen dit patroon aan. We losten het dual-truth-venster op door elk bestand en elke databaserij een entry in de version history te geven die de cutover overleeft. De MySQL editor is gebouwd om dat soort triggers zichtbaar en bewerkbaar te maken, naast de rijen die ze raken.

Het kleinste dat je vandaag kunt doen: open een van je drukste tabellen en schrijf een audit-trigger van één regel. Je weet in 24 uur meer over je migratie dan een week code lezen je zou vertellen.

— Vragen —

Kun je echt een live boekingssysteem in minder dan een minuut omschakelen?

Ja, mits je het in fases doet. De legacy code gaat eerst op read-only, lopende writes draineren, en daarna zwaai je de routing-laag om. De meeste tijd zit in het draineren, niet in het omschakelen.

Waarom MySQL-triggers in plaats van dual writes in PHP?

Bij dual writes op applicatieniveau moet elk codepad eraan denken. Een trigger vuurt bij elke write, inclusief cron jobs, rauwe SQL in legacy scripts en handmatige fixes. Minder plekken om te vergeten.

Hoe lang moet het overlap-venster duren?

Lang genoeg om één volledige business cycle te zien (een week voor de meeste consumer apps, een maand voor B2B met langere facturatiecycli) en kort genoeg dat niemand zijn aandacht laat verslappen. Vier weken klopt meestal.