← Blog

PHP

PHP 5.6-patiëntportaal migreren: cutover in vijf weken

Een PHP 5.6 patiëntportaal met 4.400 NEN 7510-geauditeerde toestemmingsregistraties en een live HL7-bus. Acht weken tot de host eruit ging. Dit was onze cutover-volgorde.

Jacob Molkenboer· Oprichter · A Brand New Company· 14 jun 2026· 9 min
Gesloten leren logboek met groen lint, kaart met messing rand, lakzegel en inktkussen op ivoor papier.

Het is een dinsdag in maart. De IT-lead van een fertiliteitskliniek in Den Haag zit in een videocall en deelt zijn scherm. Hij sleept een bestand met de naam portal_v2_FINAL_new2.php naar een map waar al 38 soortgelijke bestanden in zaten. Het portaal erachter draait sinds 2010. Het bevat 4.400 NEN 7510-geauditeerde toestemmingsregistraties waar geen auditor ons aan laat zitten zonder auditspoor. Elke twee minuten stuurt het HL7 v2-berichten via Zorgmail naar een lab in Rotterdam. PHP 5.6 is end-of-life sinds januari 2019, volgens de eigen supported versions-pagina van het PHP-project. De shared host waarop het draait, stuurde net een bericht: PHP 5.6 wordt over acht weken uitgezet.

Wij hadden er vijf.

Dit is de volgorde waarin we het werk hebben gedaan. Op zich was niets ervan slim. De slimheid zit in de volgorde.

Wat je erft bij een portaal van zestien jaar oud

Het portaal was oorspronkelijk een Joomla 1.5-site met een eigen patiëntmodule erbovenop gebouwd. In 2014 was de Joomla-schil eruit gesloopt en de rest was kale PHP, met sessions in $_SESSION, queries opgebouwd met string-concatenatie, en een mysql_real_escape_string-wrapper die iemand had hernoemd naar safe(). MySQL was 5.5, met utf8 (niet utf8mb4), waardoor een emoji in een vrij-tekst toestemmingsnotitie stilletjes werd afgekapt. Er waren 312 tabellen. Ongeveer 110 daarvan werden niet gebruikt. Dat ontdekten we pas na twee weken.

Het schema had vier dingen die we niet mochten breken:

  1. patient_consent: 4.400 rijen, NEN 7510-geauditeerd, in 2023 afgetekend door een externe auditor. Elke kolom moest in de nieuwe database landen met dezelfde naam, hetzelfde type en dezelfde row hash.
  2. hl7_outbox en hl7_inbox: de bus-tabellen voor Zorgmail. Het lab in Rotterdam verwacht een ACK binnen 30 seconden.
  3. audit_event: append-only, 1,2 miljoen rijen. Moet zeven jaar lang doorzoekbaar blijven.
  4. appointment_slot: de boekingsagenda waarin het front-desk-team de hele dag werkt.

De rest was bespreekbaar.

Stap 1: schema bevriezen, contract schrijven

Voordat we de nieuwe stack aanraakten, deden we één ding: we schreven een contracttest tegen de live MySQL. Die dumpte het schema, hashte elke rij in patient_consent, en legde de exacte byte layout van één uitgaand HL7-bericht vast. We draaiden hem vijf weken lang elke nacht tegen productie. Veranderde er iets aan de legacy-kant zonder dat wij dat hadden goedgekeurd, dan ging de test om 06:00 de volgende ochtend op rood en zetten we de boel stil.

<?php
// nightly_contract.php, runs at 03:00 on the legacy host
$pdo = new PDO('mysql:host=localhost;dbname=portal;charset=utf8', $u, $p);
$hash = $pdo->query(
  "SELECT SHA2(GROUP_CONCAT(
     CONCAT_WS('|', id, patient_id, scope, signed_at, revoked_at)
     ORDER BY id
   ), 256) AS h FROM patient_consent"
)->fetchColumn();
file_put_contents('/var/log/contract/consent.hash', $hash . "\n", FILE_APPEND);

Het zijn zes regels echt werk. Het heeft ons twee keer gered.

Stap 2: Postgres als shadow achter de live MySQL

We zetten een Postgres 16-instance op in hetzelfde Amsterdamse datacenter. Het schema migreerden we niet met de hand. We gebruikten pgloader voor de eerste pass en schreven daarna een klein Hono-script dat de dingen rechttrok waar pgloader niets mee kan: ENUM-kolommen, TINYINT(1) die boolean had moeten zijn, en de verbreding van utf8 naar utf8mb4.

Voor de doorlopende replicatie gebruikten we Debezium dat de MySQL-binlog naar Kafka leest, met een kleine consumer die in Postgres schrijft. We hadden ook gekeken naar Maxwell en een eigen CDC-laag, maar alleen Debezium gaf ons de volgorde-garantie waar we bij HL7-berichten op durfden bouwen. Binnen twee dagen hadden we een Postgres-kopie die minder dan een seconde achterliep op productie.

Waarschuwing

MySQL 5.5 staat standaard op binlog-format STATEMENT. Zet hem op ROW voordat je replicatie start, en maak eerst een back-up. Statement-based replicatie corrumpeert stilletjes rijen met niet-deterministische functies zoals NOW() in triggers.

Stap 3: Hono opzetten als read-only mirror

We kozen Hono op Node 22 voor de API-laag, omdat het klein is, eersteklas TypeScript heeft en zowel op Node als op de edge ongewijzigd draait. Remix stond ervoor voor de schermen die patiënten zien; het front-desk-team bleef tot week vier op de oude PHP-UI werken.

De eerste Hono-service deed precies één ding: GET /patients/:id/consent serveren vanuit Postgres. Dezelfde JSON-vorm als het legacy-endpoint. Byte-identieke responses, gecontroleerd door een diff harness die beide backends met dezelfde patiënt-ID's bevroeg en de bodies vergeleek.

import { Hono } from 'hono'
import { sql } from './db'

export const app = new Hono()

app.get('/patients/:id/consent', async (c) => {
  const id = Number(c.req.param('id'))
  const rows = await sql`
    SELECT id, patient_id, scope, signed_at, revoked_at
    FROM patient_consent
    WHERE patient_id = ${id}
    ORDER BY id
  `
  return c.json(rows)
})

Toen de diff harness tien dagen op rij groen was, lieten we Remix uit Hono lezen. Writes liepen nog steeds via de oude PHP. De kliniek merkte er niets van.

Stap 4: dual-write via een router

Dit is waar de meeste replatform-trajecten misgaan. De reflex is om alle writes op een rustige zondag in één keer om te zetten. Met een HL7-bus kun je dat niet doen, want het lab in Rotterdam heeft geen rustige zondag.

In plaats daarvan zetten we voor elke write-endpoint een router. Elk request kreeg een routing key (de patiënt-ID) en een percentage. Op 5% schreef de router eerst naar Postgres, daarna naar MySQL, en vergeleek de twee. Op 25% schreef hij parallel. Op 100% werd MySQL de shadow.

async function dualWrite(req: ConsentUpdate) {
  const route = router.decide(req.patientId) // 'legacy' | 'shadow' | 'dual'
  if (route === 'legacy') return legacy.write(req)
  if (route === 'shadow') return shadow.write(req)

  const [a, b] = await Promise.allSettled([
    shadow.write(req),
    legacy.write(req),
  ])
  if (a.status !== b.status) await alerts.divergence(req, a, b)
  return a.status === 'fulfilled' ? a.value : Promise.reject(a.reason)
}

Het divergentie-alert ging drie keer af in week drie. Alle drie keer dezelfde bug: een Nederlandse postcode met een kleine letter, die PHP normaliseerde en Postgres niet. We voegden een citext cast toe en gingen door.

Stap 5: HL7 doorspelen naar een parallelle Zorgmail-listener

Zorgmail is de berichtenruggengraat van de Nederlandse zorg. Het portaal ontving HL7 v2.5-berichten van het lab en bevestigde ze met een MSH|...|ACK-reply binnen de timeout van 30 seconden. Misten we een ACK, dan zette het lab het bericht in de wachtrij en deed retries, en na drie retries werd er in Rotterdam iemand gepiept. Dat was de fout die wij niet konden hebben.

We verplaatsten het Zorgmail-endpoint niet. We zetten een tweede listener op op een ander subdomein, vroegen het lab om berichten daar tijdens het cutover-venster naartoe te spiegelen, en speelden elk bericht door beide backends. De nieuwe listener schreef naar Postgres en produceerde een eigen ACK, die we weggooiden. Alleen de legacy-ACK kwam bij het lab terecht.

In week vier, toen de diff harness negen dagen op rij schoon was, vroegen we het lab het primaire endpoint om te zetten. De legacy-listener bleef nog drie weken als secondary draaien.

Stap 6: verkeer omleggen in stappen van vijf procent

De Remix-app voor patiënten begon op een maandagochtend op 5% van de logins. Gebruikers die naar Postgres werden gerouteerd, kregen een session flag, een cookie en een kleine banner met de tekst: "je test het nieuwe portaal, klik hier als er iets raar uitziet." Twaalf mensen klikten. Negen hadden echte bugs. Eén was boos over het nieuwe lettertype.

We bleven 48 uur op 5%, en gingen daarna in negen dagen door 5, 15, 35, 60, 100. Elke stap vroeg: nul nieuwe divergentie-alerts in de afgelopen 24 uur, nul HL7-retries, nul gaten in de NEN 7510-auditlog. Voldeed één van die drie niet, dan rolden we binnen tien minuten terug naar de vorige stap door de router-config om te zetten.

Stap 7: het auditlog auditen

NEN 7510 is de Nederlandse informatiebeveiligingsnorm voor de zorg, het lokale profiel boven op ISO/IEC 27001. De auditor met wie we werkten wilde drie dingen: append-only opslag, zeven jaar retentie, en een aantoonbare chain of custody van de legacy-tabel audit_event naar wat we daarvoor in de plaats zetten.

We vervingen hem niet. We lieten de tabelnaam, de kolommen en de row-ID's identiek. De nieuwe audit writer (een Hono-middleware) inserteerde in Postgres met dezelfde monotoon oplopende integer als de PHP-code, door de volgende ID uit een sequence te lezen die we bij de cutover hadden geseed met MAX(id) + 1 uit de legacy-tabel. De auditor tekende de migratie in twee uur af. Dat was het goedkoopste uur van het project.

De dag van de omschakeling

De eigenlijke cutover duurde elf minuten. Router-config: 100% Postgres. DNS: naar de Remix-edge. De PHP-host ging in read-only modus en bleef drie weken draaien als forensische kopie. We zaten van 06:00 tot 09:00 op de call en hielden de dashboards in de gaten. De eerste patiëntboeking via de nieuwe stack kwam binnen om 06:14, een vrouw van 31 die een vervolgafspraak bevestigde. We hielden het Slack-kanaal de hele dag open. Het front-desk-team belde één keer: ze wilden de oude kleuren van de appointment slots terug. Dat hadden we in 20 minuten opgelost.

Wat we anders zouden doen

Drie dingen.

We hebben vier dagen aan een eigen CDC-consumer geschreven voordat we toegaven dat Debezium de juiste keuze was. Gebruik Debezium.

We hebben de schema-cleanup (utf8 naar utf8mb4, het droppen van de 110 ongebruikte tabellen) binnen het cutover-venster gedaan. Doe dat erna. De nieuwe stack maalt niet om de dode tabellen.

We vertrouwden de diff harness pas na negen dagen genoeg om HL7 om te zetten. We hadden hem na vijf dagen al moeten vertrouwen. De marginale waarde van dag zes tot en met negen was nul, en hij kostte ons een weekend.

Tot slot

Toen we het nieuwe patiëntportaal voor die Haagse kliniek bouwden, zat het lastige niet in de framework-keuze. Het zat in de volgorde van de stappen, en in de bereidheid om de legacy-stack lang te laten draaien nadat de nieuwe live stond. Dit soort werk doen we vanuit onze legacy-migraties, vooral voor klanten in de zorg, finance en de publieke sector die zich geen onderhoudsvenster kunnen veroorloven.

Vermoed je dat je op een vergelijkbaar portaal zit: open een terminal, draai php -v op de host, en vraag je auditor wat er zou gebeuren als die versie over acht weken weg is. Het antwoord is je roadmap.

Kern

Verleg writes in stappen van vijf procent, spiegel de HL7-bus via een tweede listener, en zet een berichtenbus in de zorg nooit in één keer om.

FAQ

Waarom Remix én Hono, in plaats van één framework?

Remix rendert de schermen voor patiënten met progressive enhancement, wat uitmaakt in een kliniek op slechte wifi. Hono doet de API en HL7-ingest met een kleiner oppervlak dan een volledig Node-framework, en draait ongewijzigd op de edge.

Hoe behoud je de NEN 7510-chain of custody van het auditlog tijdens een migratie?

Houd de tabelnaam, kolommen en row-ID's van het auditlog identiek, seed de nieuwe sequence bij de cutover met MAX(id)+1 uit de legacy-tabel, en draai elke nacht hash-diffs tegen de live bron totdat de auditor aftekent.

Wat betekent shadow traffic hier in de praktijk?

Het betekent dat de nieuwe stack een kopie krijgt van echte productie-requests, naar zijn eigen database schrijft en responses produceert die je weggooit. Je vergelijkt ze met de legacy-responses totdat ze lang genoeg overeenkomen om de switch te vertrouwen.

Kan dit ook zonder een berichtenbus zoals Zorgmail in beeld?

Het patroon is hetzelfde: dual-write, percentage-router, diff harness, in stappen ramppen. De bus verandert maar één ding: zet hem nooit in één stap om, want de ontvanger weet niet dat je aan het migreren bent.

Hoe lang moeten beide stacks na de omschakeling parallel draaien?

Lang genoeg om je auditor én je eigen zenuwen gerust te stellen. Voor NEN 7510 hielden we de legacy-stack drie weken na de omschakeling leesbaar, en de auditdata doorzoekbaar voor de volledige zeven jaar retentie.

phpmysqllegacy sitesmigrationcase studyarchitecture

Iets bouwen?

Start een project