PHP
Van PHP 7.2 naar Remix: shadow-cutover in zeven weken
Coatings-distributeur in Mechelen met 36 man draait zijn productcatalogus op Symfony 2.8. Dit is de shadow-traffic-cutover van zeven weken waarmee we 'm uitfaseerden zonder één SDS-link te breken.

Het is vrijdag in Mechelen. De magazijnchef heeft net het laatste pallet tweecomponenten-epoxy voor een scheepswerf in Vlissingen afgetekend, en achter hem vuurt een 13 jaar oude catalogus om 23:50 stilletjes zijn nachtelijke artikelsync af tegen AFAS Profit. De catalogus draait op PHP 7.2 en Symfony 2.8. Beide krijgen al jaren geen support meer. Niemand in het team wil degene zijn die om drie uur 's nachts de 22.000 REACH-fiches breekt.
Dit is het playbook van zeven weken om die catalogus over te zetten naar Remix, Hono en Postgres met shadow traffic, geschreven vanuit de cutover die we net hebben uitgerold voor een industriële coatings-distributeur met 36 medewerkers.
De uitgangssituatie
De distributeur levert primers, tweecomponenten-systemen en specialty coatings aan scheepswerven, brugbouwers en staalconstructeurs in België, Nederland en Noord-Frankrijk. De catalogus is voor vier dingen tegelijk de bron van waarheid:
- 22.000 REACH-fiches met gevarenpictogrammen, UN-nummers en blootstellingsnotities
- Een nachtelijke artikelsync vanuit AFAS Profit die SKU's aanmaakt en prijst
- Een lange redirect-historie van SDS PDF-URL's waar regulator-portals, klant-ERP's en vergelijkingssites van concurrenten naar diep linken
- De B2B-orderflow voor ongeveer 1.400 actieve accounts
Het framework eronder is end-of-life. PHP 7.2 kreeg op 30 november 2020 zijn laatste security-fix, en Symfony 2.8 was een jaar daarvoor al uit long-term support. De composer.lock was sinds 2019 niet meer aangeraakt. De MySQL 5.7-database stond op het punt hetzelfde te doen.
De opdracht van de CFO was kort. Moderniseer de stack. Laat geen SDS-PDF 404 geven. Breek niets aan de nachtelijke AFAS-sync. Geef de accountant niets om over te schrijven.
Waarom shadow traffic en geen big-bang cutover
Industriële chemicaliën worden dieper gelinkt dan bijna elke andere B2B-catalogus die we ooit hebben herbouwd. REACH verplicht leveranciers om veiligheidsinformatiebladen beschikbaar te stellen, en klanten draden die URL's direct in hun procurement-systemen. Een 404 op een SDS-pad is geen zachte 404. Het is een telefoontje van een compliance officer.
Big-bang cutovers gaan op twee manieren stuk in dit soort systemen. Of de nieuwe stack heeft een bug die niemand had opgemerkt omdat de oude er tien minuten parallel naast draaide, of de rollback is zo pijnlijk dat het team door slechte data heen blijft duwen omdat ze niet terug durven. Shadow traffic laat je het oude systeem aan echte gebruikers serveren terwijl het nieuwe in het donker dezelfde requests draait. Je meet het gat voordat er iemand pijn van krijgt.
Week 1, de oude catalogus lezen
We refactoren niet wat we niet begrijpen. Week één was drie engineers en de lead developer van de klant die vier dagen samen de Symfony 2.8-codebase doorlazen. Op vrijdag hadden we drie artefacten:
- Een schemadiagram van alle 64 MySQL-tabellen, met foreign keys die in code bestonden maar niet in de database
- Een platte lijst van elke route die de applicatie serveerde, gelabeld als HTML, JSON, PDF of redirect
- Een lijst van elk extern systeem dat de catalogus aanraakte, inclusief de AFAS GetConnector, twee EDI-pijplijnen van klanten en één Excel-macro die de magazijnchef sinds 2014 onderhield
De Excel-macro was de verrassing. Hij trok elke dinsdag om 06:30 een CSV door de publieke catalogus, parste het met een regex en printte piklijsten uit. Niemand had het gedocumenteerd. Shadow traffic ving 'm op in week vijf, want de User-Agent loog nooit.
Week 2, het Postgres-schema en de eerste import
We hebben de MySQL-drift niet meegenomen naar Postgres. Het nieuwe schema was met de hand geschreven, met fatsoenlijke foreign keys, check constraints op de REACH-statuskolom, en een gegenereerde tsvector voor full-text search over Nederlandse, Franse en Engelse beschrijvingen.
CREATE TABLE article (
artikelcode TEXT PRIMARY KEY,
ean TEXT,
description_nl TEXT NOT NULL,
description_fr TEXT,
description_en TEXT,
reach_status TEXT NOT NULL
CHECK (reach_status IN ('compliant','exempt','review')),
un_number TEXT,
hazard_pictograms TEXT[],
sds_current_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX article_search_idx ON article
USING GIN (to_tsvector('dutch', coalesce(description_nl,'')));
Doctrine's lazy-load proxies hielden sinds 2018 drie kolommen verborgen voor de codebase. De oude MySQL sloeg ze op als nullable VARCHAR(255) en de applicatie schreef er vrolijk niets in. Twee daarvan hebben we omgezet naar echte enums, de derde hebben we weggegooid. Eén van die enums bleek load-bearing in een Power BI-rapport waar niemand iets over had gezegd. We kwamen erachter in week vijf en patchten dezelfde middag de rapportquery.
De artikelsync ging van een Symfony console command die SOAP-responses in Doctrine-entiteiten trok, naar een kleine Node-worker die de AFAS GetConnector-output leest, normaliseert naar TSV en met COPY in Postgres streamt. De hele nachtjob ging van 47 minuten naar onder de vier.
Week 3, de Hono API achter een feature flag
We bouwden de nieuwe read-API in Hono. De response-vormen kopieerden de oude Symfony-controllers byte voor byte, inclusief een handvol typo's in JSON-keys waar klantintegraties van afhankelijk waren. De tests werden gegenereerd uit een week aan productie-HTTP-captures.
import { Hono } from 'hono'
const api = new Hono<{ Variables: { db: DB } }>()
api.get('/api/article/:code', async (c) => {
const code = c.req.param('code')
const row = await c.var.db.article.findOne(code)
if (!row) return c.notFound()
return c.json({
artikelcode: row.artikelcode,
omschrijving: row.description_nl,
reach: row.reach_status,
sds_url: row.sds_current_url,
pictogrammen: row.hazard_pictograms,
})
})
export default api
Achter een feature flag beantwoordden de nieuwe endpoints alleen shadow-callers. Niets in productie ging nog naar Hono.
Week 4, de Remix-frontend
Remix had de juiste vorm voor een catalogus. Forms posten naar dezelfde URL waar ze van renderen, loaders halen data één keer op de server op, en progressive enhancement dekt de klanten af die nog op Edge draaien in een verffabriek. In week vier hebben we 14 routes opnieuw gebouwd: productpagina, categoriepagina, zoekfunctie, login, winkelmand, de vijf checkout-stappen, account, orderhistorie, de SDS-viewer en de fallback voor statische content.
Eén Nginx-regel stond voor beide stacks. Een request met X-ABN-Shadow: 1 ging naar Remix; al het andere bleef naar Symfony gaan. De Remix-app was bereikbaar op het productiedomein, op productiedata, zonder risico voor echte klanten.
Week 5, shadow traffic en HTML diffen
Hier verdient de migratie zichzelf terug. We forken aan de edge 100% van het read-traffic. Elk request hit Symfony (de bron van waarheid) en Remix (de kandidaat) parallel. Alleen de Symfony-response ging terug naar de browser. De Remix-response werd genormaliseerd, vergeleken en gelogd.
// Edge worker, shadow comparator
const upstream = await fetch(legacyOrigin, request)
const candidate = await fetch(candidateOrigin, request.clone(), {
headers: { 'X-ABN-Shadow': '1' },
})
const [a, b] = await Promise.all([
upstream.clone().text(),
candidate.text(),
])
const diff = compareCanonical(a, b) // strips CSRF, timestamps, nonces
if (diff.bytes > 0) {
ctx.waitUntil(logDiff({
url: request.url,
legacyStatus: upstream.status,
candidateStatus: candidate.status,
sample: diff.firstHunk,
}))
}
return upstream // user always sees the legacy
Canonicalisatie is waar shadow-diff-projecten stilletjes doodgaan. De naïeve versie vergelijkt ruwe bytes, rapporteert op dag één een verschil van 6%, en op vrijdag heeft het team het opgegeven. De eerlijke versie is een lijst van elke bron van legitieme variatie die je kunt bedenken, aan beide kanten gestript vóór de vergelijking. De onze haalde CSRF-tokens eruit, request-ID's, ISO-timestamps die binnen vijf seconden van elkaar lagen, de Symfony debug-nonce, twee cookie-gebaseerde personalisatie-strings, en een stabiele sortering op elke DOM-node die een lijst omhulde die in niet-deterministische volgorde gerenderd werd. Alles wat we niet canonicaliseerden kwam terug als een false-positive en begroef de echte.
Op dag drie van week vijf zaten we op 99,4% byte-identieke canonieke HTML over ongeveer 180.000 dagelijkse requests, met een false-positive-rate van 0,04%. De resterende echte 0,6% bestond uit drie soorten bugs: een verkeerde locale-fallback voor 41 Franse beschrijvingen, een ontbrekend gevarenpictogram voor één klasse polyurethaan-primer, en de Excel-macro uit week één die tabs verwachtte terwijl Remix twee spaties stuurde. Elke fix kostte minder dan een halve dag, want het diff-log vertelde precies welke URL, welke DOM-node en welke inputrij.
Week 6, de SDS PDF-redirectketen
13 jaar lang had het bedrijf zijn SDS-PDF's verplaatst. Een PDF uit 2015 stond op /sds/EPX2.pdf. Datzelfde product staat vandaag op /assets/sds/2024-06/epx2-v3.pdf. Klant-ERP's en vergelijkingssites van concurrenten linken nog steeds naar het pad uit 2015. Sommigen linken naar vier tussenliggende paden waarvan niemand in het huidige team zich herinnert dat ze ooit live zijn gegaan.
We schreven een redirect map van 1.412 regels. Elke entry bestond uit een diep-gecontroleerde bron-URL, het huidige doel, de datum waarop het laatst geverifieerd was, en een telling van unieke verwijzers uit de laatste 90 dagen access logs. Daarna serveerden we 'm vanuit Hono.
api.get('/sds/:legacy', async (c) => {
const legacy = c.req.param('legacy')
const target = c.var.sdsMap.get(legacy)
if (!target) {
// 410 Gone, not 404. The PDF was real once.
return c.text('Document withdrawn.', 410)
}
return c.redirect(target.url, 301)
})
Als je 404 antwoordt op een deep link van een regulator, mailt de compliance officer van de klant je CEO vóór de lunch. Gebruik 410 Gone voor ingetrokken PDF's. Bewaar 404 voor URL's die je nooit hebt gehad.
Week 7, de cutover en het rollback-pad
Op maandag hadden we drie weken schone shadow-diff achter ons. De cutover was een kwestie van Nginx-weights aanpassen. Read-traffic ging dinsdagochtend naar 10% Remix, dinsdagmiddag naar 50%, woensdag rond lunchtijd naar 100%. Admin en de AFAS-artikelsync gingen vrijdag over na sluiting van het magazijn. We hielden de Symfony-stack nog twee weken warm als rollback van 12 seconden.
Tijdens de 10/50/100-ramp keek het team op één muur naar vier dashboards: HTTP-statuscodes per route, hit-rate van de SDS PDF-redirects, recordaantal en lag van de AFAS-sync, en een synthetische checkout die elke 90 seconden een testorder van één cent plaatste. De rollback-triggers stonden vooraf opgeschreven in begrijpelijke taal en waren afgetekend door de CFO: elke 5xx-rate boven 0,3% die vijf minuten aanhield, elke SDS 410 boven de baseline van 90 dagen, of elke afwijking in het totaal van de synthetische checkout. Het punt van opschrijven was niet de drempels. Het was zodat niemand om 23:00 op een dinsdagavond nog een oordeel hoefde te vellen.
We hebben de rollback nooit gebruikt. Op de cutover-vrijdag sliep de CFO voor het eerst in een jaar.
Wat we niet meer zo zouden doen
Vier dingen, in de volgorde waarin het pijn deed.
Eén, we namen zes kleine bugs mee omdat ze in beide systemen identiek waren. Twee daarvan bleken load-bearing in de integratietests van klanten. Fix ze in shadow, niet erna.
Twee, we hadden de SDS-redirect-mapping drie keer onderschat. Reken op 1.000+ entries, geen 300. Het access log is je enige bron van waarheid.
Drie, we hebben de AFAS-artikelsync op de cutover-nacht bewust tegen beide databases laten lopen, en hadden daar geen budget voor gereserveerd. De nachtelijke CPU-rekening verdubbelde. Het was het waard voor het vertrouwen in de diff, maar zeg het wel van tevoren tegen je financiële mens.
Vier, we behandelden de Excel-macro van de magazijnchef als een curiositeit in plaats van als een stakeholder. Hij parste die CSV sinds 2014 elke dinsdag om 06:30 en kende de catalogus beter dan de code. We hadden 'm moeten uitnodigen voor de diff-review in week vijf. Hij had het tab-vs-spatie-probleem op dag één gespot en ons een donderdag bespaard.
Toen we dit voor de distributeur in Mechelen draaiden, was het bijna niet de framework-upgrade die ons brak, maar het deep linking aan de regulator-kant. De Hono-redirect-map is nu het eerste artefact dat we opleveren bij elke legacy-migratie die we oppakken. Als jouw stack er net zo uitziet als die van hen in 2019, doet de volgorde van handelen er meer toe dan het doelframework.
Wil je vandaag ergens beginnen, open dan het access log van vorige week op je oude catalogus. Grep op /sds/, of wat jouw equivalent is van een pad richting de regulator. Tel de unieke paden. Dat getal, keer drie, is je budget voor week zes.
Kern
Een PHP-catalogusmigratie gaat vooral over URL's en de data-sync. De framework-swap is het makkelijke deel.
FAQ
Waarom shadow traffic in plaats van een big-bang cutover?
Met shadow traffic beantwoordt het nieuwe systeem echte requests parallel terwijl het oude nog de gebruikers serveert. Je meet het verschil voordat er iemand pijn van krijgt, en het rollback-pad blijft één Nginx-config-swap.
Waarom Hono en niet Express of Fastify?
Hono draait dezelfde handler-code aan de edge en in Node, waardoor we 'm in de Cloudflare-laag konden shadow-deployen voordat we 'm naar de origin doorzetten. Express of Fastify was prima geweest, maar alleen in de origin.
Wat gebeurt er na de migratie met oude SDS PDF-URL's?
Elk historisch SDS-pad is via een Hono redirect-handler op zijn huidige doel gemapt. Live PDF's geven 301 terug. Ingetrokken PDF's geven 410 Gone, nooit 404, zodat deep links van regulators niet stilletjes stuk gaan.
Hoe lang duurt een vergelijkbare PHP-catalogusmigratie?
Zeven weken voor een catalogus met 22.000 producten, één ERP-sync en één regulator-redirectketen. Reken op ongeveer een week extra per extra integratie. De meeste tijd zit in shadow-diffing, niet in framework-code.