← Blog

PHP

Van legacy PHP naar Laravel 12: dealerportaal in zes weken

Een 16 jaar oud PHP 5.6-dealerportaal in Venlo moest in zes weken op Laravel 12 draaien zonder één AS2-bericht te verliezen. Dit is het draaiboek dat we gebruikten.

Jacob Molkenboer· Oprichter · A Brand New Company· 30 mrt 2025· 9 min
Leren logboek halfopen met messing sleutel op vergeelde pagina's, indexkaart, rode lakzegel, groen lint op ivoor papier.

Op een maandag in maart trok de operations lead van een autoparts-distributeur met 38 medewerkers in Venlo een spreadsheet open. Eenenveertig regels. Elke regel was een EDI-partner: een OEM, een onderdelengroothandel, een landelijke keten in Duitsland of België. Allemaal stuurden ze orders, verzendberichten en facturen via AS2 naar één URL die al zestien jaar antwoordde. De PHP-versie achter die URL was 5.6. De MySQL-versie was 5.5. Geen van beide had sinds 2018 een security patch gehad.

De deadline die ze meekreeg was zes weken. Het portaal moest vóór het einde van het kwartaal op Laravel 12 en Postgres draaien, omdat de cyberverzekering bij verlenging een clausule had opgenomen over runtimes die geen support meer krijgen. Eenenveertig partners vragen om hun endpoints opnieuw te richten was geen optie. De URL's moesten blijven antwoorden.

Dit is het draaiboek dat we hebben gedraaid.

De echte surface area in kaart brengen voordat er code wordt aangeraakt

Twee dagen lezen, nul commits. We liepen de oude codebase door, vanaf het routes-bestand naar buiten toe. Elk endpoint kreeg een regel in een spreadsheet: HTTP-methode, vorm van de request body, vorm van de response body, welke tabellen werden gelezen, welke geschreven, welke side effects werden getriggerd.

De dealer-UI bestond uit 47 routes. Het interne admin-deel uit 23. De AS2-ontvanger was één endpoint dat een volledige EDIFACT-decoder omsloot. De cronjobs telden nog eens 14 entry points bij elkaar op (factuurgeneratie, MDN-retries, refresh van partnercertificaten).

Wat de exercitie boven water haalde: 19 routes waren dood. Geen enkele partner had ze in twaalf maanden geraakt. De rapportagemodule uit 2014 was in 2022 vervangen door een Power BI-dashboard, maar dat was de developers nooit verteld. Die routes schrappen scheelde op dag drie al een week werk.

Een migratie die begint met "we gaan het systeem herschrijven" loopt vast. Een die begint met "we gaan de 64 endpoints herschrijven die echt gebruikt worden" haalt de deadline.

Laravel 12 ernaast neerzetten, niet erbovenop

De nieuwe stack kreeg een eigen host. Laravel 12, PHP 8.4, Postgres 16, Redis 7. Hetzelfde VLAN als de oude bak. Geen DNS-wijzigingen. Nog geen reverse proxy.

Het eerste op de nieuwe host was geen controller. Het was een read-only replica van de oude MySQL-database, via mydumper gestreamd naar een staging Postgres-instance en elke nacht ververst. Dat gaf ons een plek om schemavertalingen te valideren zonder productiedata aan te raken.

Het tweede op de nieuwe host was één nginx-vhost die de productie-hostname beantwoordde op een niet-routeerbaar intern IP. Vanaf een jump host testten we elk endpoint daartegen. Als een route de verkeerde vorm teruggaf, wisten we het voordat een partner er iets van merkte.

De AS2-endpoints stabiel houden tijdens de overstap

AS2 is RFC 4130. De ontvanger ontsleutelt een ondertekende S/MIME-payload, verifieert het certificaat van de partner, schrijft het EDIFACT-bericht naar een queue en stuurt een ondertekende MDN-bevestiging terug. Eenenveertig partners hadden de vingerafdruk van ons certificaat hardgepind in hun AS2-software. Sommige draaiden Mendelson. Andere IBM Sterling. Eentje een custom Java-daemon uit 2009.

We hadden drie opties. Optie A: een nieuwe AS2-ontvanger bouwen in Laravel. Het schoonst, maar dat betekende óf certificaten opnieuw uitgeven (elke partner heeft daar change control op) óf de private key naar de nieuwe app verhuizen. Optie B: de oude PHP 5.6-AS2-ontvanger op een hardened jump box laten draaien, achter een strakke allowlist, en gedecodeerde berichten via een interne HTTP-call naar Laravel doorzetten. Optie C: een kant-en-klare ontvanger als OpenAS2 plaatsen en intern herrouteren.

We kozen optie B. De oude ontvanger was 800 regels PHP die we al door en door kenden. Hij had buiten de AS2-poorten geen blootstelling aan internet. We bevroren de code, zetten er een WAF voor die alleen verkeer uit de 41 IP-ranges van partners doorliet, en gaven hem één taak: ontvangen, verifiëren, het gedecodeerde EDIFACT in een Redis-stream dumpen die Laravel consumeerde.

Daarmee bleef elk certificaat, elke URL en elke MDN-signature intact. De partners zagen geen verandering.

Waarschuwing

Meng AS2-private keys nooit met web-app-secrets. De receiver-key krijgt eigen filesystem-permissies, een eigen back-up-rotatie en een eigen access log. Wie dat niet scheidt, krijgt op enig moment een stagiair met .env-toegang die een partnervertrouwensincident veroorzaakt.

Zes weken dual-write, één bron van waarheid op dag 43

De eerste drie weken bleef de oude PHP-app het write path. Laravel consumeerde change-data-capture vanuit MySQL via Debezium, vertaalde rijen naar het nieuwe Postgres-schema en serveerde een read-only versie van de dealer-UI op portal-v2.internal. Interne gebruikers vergeleken schermen naast elkaar.

In week vier en vijf zetten we het dealer-loginformulier om naar dubbel schrijven. Elke order van een dealer raakte de oude PHP-controller én een Laravel-job, parallel. Een reconciler liep elke vijftien minuten en vergeleek rij-aantallen, totalen en content-hashes per tabel.

<?php

namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Services\LegacyMysqlBridge;
use Illuminate\Support\Facades\Log;

class MirrorOrderToLegacy
{
    public function __construct(
        private readonly LegacyMysqlBridge $legacy,
    ) {}

    public function handle(OrderPlaced $event): void
    {
        try {
            $this->legacy->writeOrder($event->order);
        } catch (\Throwable $e) {
            // The new system is canonical. The old one is a mirror.
            // We log, alert, and reconcile out of band. We do not throw.
            Log::channel('legacy-mirror')->error('mirror failed', [
                'order_id' => $event->order->id,
                'error'    => $e->getMessage(),
            ]);
        }
    }
}

De reconciler ving in de eerste week zeven verschillen. Zes waren een verschil in decimal-precisie (MySQL was DECIMAL(10,2), Postgres was NUMERIC(12,4)). Eén was een echte bug in hoe we een partner-specifieke kortingscode vertaalden. Geen ervan was via een testsuite boven water gekomen. De reconciler legde ze allemaal per rij bloot.

In week zes werden de oude PHP-controllers read-only. Laravel werd canoniek. AS2 bleef op de jump box.

MySQL 5.5 vertalen naar Postgres zonder dataseman­tiek te verliezen

MySQL 5.5 is mild op manieren waarop Postgres dat niet is. Lege strings als datums. Impliciete type coercion. ENUM-kolommen die ongeldige waarden stilletjes accepteren als de SQL-mode los staat. Het schema was geschreven voordat strict mode bestond.

We schreven een vertaler, geen dumper. Voor elke tabel las een klein Python-script de MySQL-DDL, mapte de types, repareerde nullability en produceerde de Postgres-DDL plus een data-copy SQL waarin de opschoonregels zaten. De opschoonregels waren expliciet, in code, en werden gereviewd.

-- old (MySQL 5.5)
-- CREATE TABLE invoices (
--   id INT AUTO_INCREMENT PRIMARY KEY,
--   issued_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
--   amount DECIMAL(10,2) NOT NULL,
--   status ENUM('draft','sent','paid','void') NOT NULL DEFAULT 'draft'
-- );

CREATE TYPE invoice_status AS ENUM ('draft', 'sent', 'paid', 'void');

CREATE TABLE invoices (
  id          BIGSERIAL PRIMARY KEY,
  issued_at   TIMESTAMPTZ NOT NULL,
  amount      NUMERIC(12, 4) NOT NULL,
  status      invoice_status NOT NULL DEFAULT 'draft',
  CONSTRAINT  invoices_amount_nonneg CHECK (amount >= 0)
);

-- migration step, run inside a transaction per batch of 50,000 rows
INSERT INTO pg.invoices (id, issued_at, amount, status)
SELECT
  id,
  CASE
    WHEN issued_at = '0000-00-00 00:00:00' THEN created_at
    ELSE issued_at AT TIME ZONE 'Europe/Amsterdam'
  END,
  amount,
  status::text::invoice_status
FROM my.invoices;

De waarde '0000-00-00 00:00:00' stond in 312 rijen. Geen daarvan waren echte facturen. De CASE-clausule gebruikte de created_at van de rij als fallback. Elke fallback logden we naar een side table zodat de operations lead steekproeven kon doen. De Postgres-documentatie over datatypes is helder over conversiegedrag, en is de moeite waard om te lezen voordat je aan een migratie van deze vorm begint.

De cutover-week, uur voor uur

Vrijdagavond. Dealers waren geïnformeerd dat het portaal van 19:00 tot 23:00 in read-only mode zou staan.

19:00, de oude PHP-app werd via een config-flag op read-only gezet. De AS2-ontvanger bleef live; partners respecteren geen onderhoudsvensters.

19:15, de laatste Debezium-sync liep. Laravel consumeerde de schrijfacties van de afgelopen vier uur.

20:30, de reconciler meldde nul verschillen over 247 tabellen.

21:00, nginx op de publieke load balancer werd omgezet. De dealer-hostname wees naar Laravel. De AS2-hostname bleef naar de jump box wijzen.

21:10, smoke tests. Een echte dealer-login. Een echte order. Echte factuur-PDF-generatie. Twee mislukte PDF-renders omdat een fontbestand niet naar de nieuwe host was gekopieerd. In negen minuten opgelost.

23:00, dealers kregen mail dat het portaal weer live was. Zes weken, op de dag.

Het eerste AS2-bericht kwam binnen om 23:47, van een partner in Stuttgart met een weekendorder. De MDN ging ondertekend terug. Aan de partnerkant veranderde niets.

Wat we de volgende keer anders doen

Drie dingen.

Eén: de reconciler had het eerste moeten zijn dat we bouwden, niet het derde. Elk uur dat de dual-write zonder reconciler liep, vlogen we blind. Bouw de diff-tool vóór de nieuwe app.

Twee: de AS2-ontvanger had al een jaar eerder naar een eigen subdomein verhuisd moeten worden, toen de oude app nog in onderhoud zat. Het partnergedeelte van het dealergedeelte loskoppelen is goedkoop als het systeem rustig is. Bij de cutover is het duur.

Drie: de Power BI-integratie. Op dag 19 ontdekten we dat het rapportageteam via een ODBC-link rechtstreeks naar twee MySQL-tabellen schreef, iets waar de developers niets van wisten. Dat brak op het moment dat Postgres het overnam. We hoorden ervan via een dashboardalert om 06:42 op de ochtend na de cutover. Breng elke consumer van de database in kaart, niet alleen elke producent.

De kleinste volgende stap

Toen we de portaalmigratie voor de Venlose distributeur bouwden, was wat ons redde dat we AS2 als een bevroren oppervlak behandelden en de rest van de app als de migratie. Door de partner-endpoints zes weken langer op de oude runtime te laten, konden we de rewrite richten op de delen waar Laravel 12 echt waarde toevoegt. Zit jij op een custom PHP-portaal met EDI-partners eraan vast, dan is het kleinste nuttige wat je vandaag kunt doen een spreadsheet openen en élke consumer van je database opsommen (de dashboards, de scripts, de ODBC-links, het Excel-bestand van de analist met een opgeslagen connection string) voordat je één regel code aanraakt. Dit is het eerste uur van elke legacy migratie die we oppakken, en die lijst legt altijd iets bloot dat het team was vergeten.

Kern

Behandel het partneroppervlak als bevroren en migreer de rest. Dual-write met een reconciler wint altijd van een slim opgezet cutover-weekend.

FAQ

Waarom zes weken dual-write en niet een harde cutover in het weekend?

Dual-write legt vertaalbugs bloot tegen echt partnerverkeer voordat het oude systeem weg is. Een harde cutover vindt diezelfde bugs om 02:00 op een zaterdagochtend, zonder rollback-pad.

Kan de oude PHP 5.6-AS2-ontvanger voor onbepaalde tijd op de jump box blijven staan?

Nee. Het is een brug van zes weken. Plan binnen twaalf maanden de partner-voor-partner certificaatmigratie naar een moderne AS2-ontvanger, anders heb je het runtime-risico alleen vooruitgeschoven, niet opgelost.

Hoe ga je om met MySQL-ENUM-waarden die niet bestaan in het Postgres-ENUM-type?

Vang ze in de vertaler. Log elke rij waarvan de ENUM-waarde onverwacht is, kies per kolom een default en laat de operations lead de mapping aftekenen voordat de data-copy draait.

Is zes weken realistisch voor een portaal van 16 jaar oud van deze omvang?

Alleen als je vroeg dode routes schrapt, het partneroppervlak bevriest en de reconciler bouwt vóór de nieuwe app. Zonder die drie reken je op twaalf weken.

phpmigrationlegacy sitesmysqlintegrationsarchitecture

Iets bouwen?

Start een project