← Blog

PHP

PHP 5.6 ERP vervangen: een rolling cutover in zes weken

Een groothandel in sanitair in Mechelen, 34 mensen, draaide de orderdesk op een zelfgebouwd PHP 5.6 ERP. Dit is de cutover van zes weken die het verving zonder de telefoons stil te leggen.

Jacob Molkenboer· Oprichter · A Brand New Company· 10 jun 2026· 11 min
Open leren grootboek op ivoorpapier met messing relais, ijzeren label, groene memo, rood lakfragment, zijlicht.

De orderdesk in Mechelen heeft twee telefoons, drie monitoren en een waterkoker die al sinds 7:14 's ochtends aanstaat. Karin werkt hier sinds 2009 en tikt ordernummers in een groen-op-zwart terminal dat niemand binnen het bedrijf nog kan hercompileren. De PHP 5.6 broncode staat op een Synology in de hoek. De oorspronkelijke developer is in 2019 naar Australië verhuisd. Vorige winter rondde de BTW-berekening drie dagen lang de verkeerde kant op voordat iemand het doorhad, en de fix was een comment in het Nederlands: // tijdelijk, fixen voor kerst. Kerst 2017.

Dit is het type systeem waarvoor we gebeld worden. Geen spectaculaire crash. Een dagelijkse. De eigenaar had een offerte van €380k en acht maanden liggen van een Belgische SAP-partner om het te vervangen, en zijn tegenvoorstel aan ons was: doe het in zes weken, leg de telefoons niet plat, en laat Karin haar keyboard shortcuts houden.

Hieronder de volgorde die we daadwerkelijk hebben gevolgd, inclusief de keuzes die goed uitpakten en de twee die fout gingen. De stack werd Laravel 12, PostgreSQL 16, en een chat agent die de binnenkomende e-mailorders afhandelt die de desk vroeger met de hand overtikte. De cutover was rolling, geen big-bang. Niets werd herschreven wat niet herschreven hoefde te worden.

Week nul: lees de database, niet de code

De PHP was onleesbaar. Het MySQL 5.5 schema niet. We hebben drie dagen lang niets anders gedaan dan de live database bevragen vanaf een read replica, elke tabel in kaart brengen, en aan Karin en de magazijnmanager vragen waar elke tabel eigenlijk voor diende. De helft van de tabellen was dood. In één ervan, klanten_oud_2014, werd elke nacht geschreven door een cron die niemand zich herinnerde geïnstalleerd te hebben.

De oplevering van week nul was één A3 die boven de orderdesk werd geprikt: 41 levende tabellen, 19 dode, 6 "twijfel, laat staan". Nog geen code. Geen Jira. Het team moest de kaart vertrouwen voor we er iets op gingen verplaatsen. Bij een legacy PHP-vervanging is de database de specificatie. De code is alleen de interpretatie van wie er als laatste aan zat.

Week één: een Laravel-shell die uit de oude database leest

We hebben in week één geen data gemigreerd. We hebben een verse Laravel 12 app op de bestaande MySQL 5.5 instance gericht als secundaire connectie, Eloquent-modellen tegen de live tabellen gegenereerd, en één scherm gebouwd: een read-only orderopzoeker. Karin gebruikte dat twee dagen op haar tweede monitor voordat we haar erin lieten typen.

De truc die dit liet werken was de multi-database support van Laravel. De nieuwe app praatte voor reads met de oude database, en voor alles wat hij zelf wegschreef met een nieuwe PostgreSQL 16 instance. We hoefden de data nooit te forken.

// config/database.php
'connections' => [
    'pgsql' => [
        'driver' => 'pgsql',
        'host' => env('DB_HOST'),
        'database' => env('DB_DATABASE'),
        // ...primary, new writes land here
    ],
    'legacy' => [
        'driver' => 'mysql',
        'host' => env('LEGACY_DB_HOST'),
        'database' => 'erp_synology',
        'options' => [
            PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'latin1'",
        ],
    ],
],

De latin1-regel is niet optioneel. De oude database stond vol mojibake, en elke poging om het als UTF-8 te lezen veranderde elke é in een klantnaam in een vraagteken. We hebben de oude database in zijn eigen encoding gelaten en pas bij het uitlezen geconverteerd, per kolom, met een bekende mapping.

Week twee: de strangler om de orderinvoer heen

Het orderinvoerscherm was het drukste oppervlak in het gebouw. Veertig tot zestig telefonische orders per dag, plus walk-ins. We zaten er nog niet aan. In plaats daarvan hebben we een Laravel-scherm geschreven dat exact hetzelfde deed, met exact Karins keyboard shortcuts, en op een tweede URL gezet. Beide schermen schreven naar dezelfde MySQL-tabellen, via een dunne write-through laag die de rij ook naar PostgreSQL spiegelde.

Dit is de klassieke strangler fig vorm, maar wat operationeel telt is wie de knop omzet. Wij niet. Karin wel. Twee weken lang kon ze elke order in beide schermen invoeren. Tegen het eind van week drie opende ze de groene terminal alleen nog voor één specifieke rapportage. Dat was het signaal.

Let op

Als je de cutover-datum in het contract zet, ga je die halen en haat het team het nieuwe systeem. Laat de gebruiker zelf de dag kiezen waarop ze stoppen met het oude scherm. Ze kiezen altijd eerder dan jij gedurfd zou hebben.

Week drie: de chat agent op de inkomende mailbox

Veertig procent van de orders kwam binnen per e-mail, meestal van aannemers met een lopende account, meestal in een herkenbaar formaat: een PDF of een lijst met SKU's, een leverdatum, een werkadres. De desk tikte die vroeger over in de groene terminal. Wij hebben er een agent op gericht, op de orders@ mailbox.

De agent doet drie dingen en alleen drie: hij parset de e-mail plus eventuele PDF-bijlage, matcht regels tegen de productcatalogus met een fuzzy SKU-lookup, en maakt in het nieuwe Laravel-scherm een concept-order aan met status needs_review. Hij verstuurt niets. Hij bevestigt niets. Karin drukt nog steeds op de knop.

// app/Agents/OrderIntake.php (the loop, stripped down)
public function handle(InboundEmail $email): DraftOrder
{
    $customer = $this->matchCustomer($email->from);
    $lines    = $this->extractLines($email->body, $email->attachments);

    $matched = collect($lines)->map(fn ($l) => [
        'raw'       => $l->text,
        'sku'       => $this->catalogue->fuzzyMatch($l->text, $customer),
        'qty'       => $l->qty,
        'confidence'=> $this->catalogue->lastScore(),
    ]);

    return DraftOrder::create([
        'customer_id' => $customer?->id,
        'status'      => 'needs_review',
        'source'      => 'email_agent',
        'lines'       => $matched,
        'raw_email'   => $email->id,
    ]);
}

Wat de agent zijn geld waard maakte was niet het parsen. Het was de customer-specific SKU-lookup. Aannemer A noemt een 22mm koperen knie een knie 22. Aannemer B schrijft CU-22-90. Hetzelfde fysieke onderdeel. We hebben een alias-tabel per klant gebouwd waar de agent in schrijft zodra Karin hem corrigeert, en na drie weken zaten de confidence scores op ruwweg vier van de vijf regels boven de 0.9.

Week vier: datamigratie, op de saaie manier

We spiegelden writes naar PostgreSQL al sinds week twee, dus tegen week vier had de nieuwe database vier weken aan schone data. Wat we nog nodig hadden was de back-catalogue: klanten, producten, vijftien jaar orderhistorie.

We deden dit met één Laravel-command, 's nachts gedraaid, idempotent, met een hash op elke rij zodat herhalingen alleen veranderde rijen raakten. Geen ETL-tool. Geen Talend. Zeshonderd regels PHP. De hele migratie liep in 41 minuten op de laatste run.

De twee dingen die ons opbraken: datums opgeslagen als VARCHAR(10) in drie verschillende formaten, afhankelijk van in welk decennium de rij was weggeschreven, en een prijs-kolom die soms ex-BTW was en soms in-BTW, zonder vlag. De eerste hebben we opgelost met een kleine parser en een weigering om te gokken. De tweede door het aan de boekhouder te vragen, die precies wist wanneer de afspraak was veranderd (juni 2011, na een audit).

Week vijf: de magazijnschermen en het rapport dat niemand noemde

Elk project heeft er één. Het rapport dat niemand in de kick-off noemt en dat de eigenaar elke maandagochtend om 7:30 draait. Bij ons was dat een samenvatting van voorraadmutaties die de groene terminal produceerde als een fixed-width tekstbestand, dat de eigenaar vervolgens in Excel opende.

We hebben hem herbouwd in één Laravel-view, de kolomvolgorde exact aangehouden, en een CSV-export toegevoegd. De eigenaar gebruikte de nieuwe voor het eerst op een maandag in week vijf, en de groene terminal werd op woensdag uit het stopcontact getrokken.

Week zes: deprecation, geen deletion

De oude PHP 5.6 stack bleef nog twee maanden online, read-only, op de oorspronkelijke Synology, op een subnet dat alleen vanuit kantoor bereikbaar was. Niemand opende hem. We checkten de access log elke vrijdag. Twee reads in acht weken, beide van de boekhouder die een factuur uit 2018 opzocht. Daarna hebben we een image van de schijf gemaakt, dat naar cold storage gearchiveerd, en de doos uitgezet.

PHP 5.6 zelf valt al sinds 2019 buiten de officiële security support, en dat is de zin waarmee we het gesprek altijd openen. De doos hing niet aan het publieke internet, maar op het LAN waar hij stond zaten ook een Windows 7 machine en een printer met een bekende CVE, en dat is het realistische dreigingsmodel voor een groothandel met 34 man. Het punt van de vervanging waren niet de nieuwe features. Het punt was wegkomen van een stack waar één ongepatchte RCE op kantoor het hele orderboek mee had genomen.

Wat we fout deden

Twee dingen, allebei in week drie. We hadden onderschat hoezeer de desk leunde op een geprinte paklijst die uit de groene terminal kwam in een heel specifiek lettertype en met een specifieke kolomindeling. De eerste versie van onze vervanging was bijna goed en daardoor erger dan nutteloos. We hebben hem pixel-voor-pixel herbouwd, in een monospace font, op hetzelfde A5-papier, en toen was het in orde.

Het tweede was de agent. De eerste week lieten we hem orders onder een confidence-grens automatisch bevestigen. Hij zat er één keer naast, een T-stuk in plaats van een knie, en de aannemer stond op de bouwplaats toen de verkeerde doos arriveerde. Daarna maakt de agent altijd een concept en bevestigt Karin. De kosten zijn ongeveer twee minuten per order. De kosten van het alternatief zijn een ritje naar een bouwplaats in Antwerpen.

Toen we de order-intake chat agent voor de desk in Mechelen bouwden, liepen we ertegenaan dat de catalogus-lookup pas werkte zodra hij het persoonlijke vocabulaire van elke aannemer had geleerd. We hebben dat uiteindelijk opgelost met een alias-tabel per klant waar Karin in schrijft elke keer dat ze een concept corrigeert, en de nauwkeurigheid van de agent kruipt week na week omhoog zonder dat iemand hem expres traint.

Zit je vandaag op een PHP 5.x ERP, dan is het kleinste wat je vanmiddag kan doen: open de database in een read-only client en schrijf op welke tabellen in de afgelopen zeven dagen veranderd zijn. Die lijst is je echte systeem. De rest is steiger.

Kern

Bij een legacy PHP-vervanging is de database de specificatie en kiest de gebruiker zelf de cutover-datum. De rest is alleen maar steiger om die twee feiten heen.

FAQ

Waarom Laravel 12 en geen kant-en-klaar ERP zoals Odoo of Exact?

Kant-en-klare ERP's gaan ervan uit dat jouw processen op de hunne lijken. Na vijftien jaar zelfgebouwde workflow waren die van ons dat niet. Met Laravel konden we de bestaande shortcuts en rapporten van de desk overnemen, in plaats van 34 mensen om te scholen.

Hoe hebben jullie downtime tijdens de cutover vermeden?

Het oude PHP-scherm en het nieuwe Laravel-scherm schreven twee weken lang allebei naar dezelfde MySQL-tabellen, met een mirror naar PostgreSQL. De gebruiker koos zelf wanneer ze stopte met het oude scherm openen. Er was geen omschakeldag.

Heeft de chat agent de orderdesk vervangen?

Nee. Hij maakt vanuit inkomende e-mails en PDF's concept-orders aan in een needs-review queue. Karin bevestigt nog steeds elke order. Dat is de regel die we één keer hebben overtreden in week drie en nooit meer.

Hoe zag het database-migratietool eruit?

Eén idempotent Laravel artisan-command, zo'n 600 regels PHP, 's nachts gedraaid. Elke rij droeg een content-hash, zodat herhalingen alleen veranderde rijen raakten. De laatste run duurde 41 minuten.

Hoe lang bleef de oude PHP 5.6 stack online?

Twee maanden read-only op een intern subnet, daarna geïmaged naar cold storage. Twee reads in acht weken, beide van de boekhouder die een oude factuur opzocht.

phplegacy sitesmigrationai agentscase studyarchitecture

Iets bouwen?

Start een project