PHP
Replatforming a PHP 5.6 patient portal: a five-week cutover
A PHP 5.6 patient portal with 4,400 audited consent records and a live HL7 bus. Eight weeks until the host shut down. Here is the order we ran the five-week cutover in.

It is a Tuesday in March. The IT lead at a fertility clinic in Den Haag is on a video call, sharing his screen. He drags a file called portal_v2_FINAL_new2.php into a folder of 38 sibling files with similar names. The portal behind it has been running since 2010. It holds 4,400 NEN 7510-audited consent records that no auditor will let us touch without a paper trail. Every two minutes it pushes HL7 v2 messages over Zorgmail to a lab in Rotterdam. PHP 5.6 has been end-of-life since January 2019, per the PHP project's own supported versions page. The shared host running it has just sent a notice: PHP 5.6 will be decommissioned in eight weeks.
We had five.
This is the order we ran the work in. None of it was clever. The cleverness is in the order.
What you inherit when you inherit a sixteen-year-old portal
The portal was originally a Joomla 1.5 site with a custom patient module bolted on top. By 2014 the Joomla shell had been gutted and the rest was bare PHP, with sessions in $_SESSION, queries built with string concatenation, and a mysql_real_escape_string wrapper that someone had renamed safe(). MySQL was 5.5, with utf8 (not utf8mb4), so an emoji in a free-text consent note silently truncated. There were 312 tables. Around 110 were unused. We did not find that out for two weeks.
The schema had four things we could not break:
patient_consent: 4,400 rows, NEN 7510 audited, signed off in 2023 by an external auditor. Every column had to land in the new database with the same name, the same type, and the same row hash.hl7_outboxandhl7_inbox: the Zorgmail bus tables. The lab in Rotterdam expects an ACK inside 30 seconds.audit_event: append-only, 1.2 million rows. Required to be queryable for seven years.appointment_slot: the booking calendar the front-desk team lives in.
Everything else was negotiable.
Step 1: Freeze the schema, write the contract
Before touching the new stack, we did one thing: we wrote a contract test against the live MySQL. It dumped the schema, hashed each patient_consent row, and recorded the exact byte layout of one outbound HL7 message. We ran it nightly against production for the full five weeks. If anything changed on the legacy side that we had not authorised, the test went red at 06:00 the next morning and we paused.
<?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);
It is six lines of real work. It saved us twice.
Step 2: Shadow Postgres behind the live MySQL
We stood up a Postgres 16 instance in the same Amsterdam datacentre. We did not migrate the schema by hand. We used pgloader for the first pass and then wrote a thin Hono script to fix up the things pgloader cannot reason about: ENUM columns, TINYINT(1) that should be boolean, and the utf8 to utf8mb4 widening.
For ongoing replication we used Debezium reading the MySQL binlog into Kafka, with a small consumer writing into Postgres. We considered Maxwell and a custom CDC layer, but Debezium had the only ordering guarantee we trusted around HL7 message arrival. Inside two days we had a Postgres copy that lagged production by under one second.
MySQL 5.5 binlog format defaults to STATEMENT. Switch to ROW before you start replication, and back up first. Statement-based replication will silently corrupt rows with non-deterministic functions like NOW() inside triggers.
Step 3: Stand up Hono as a read-only mirror
We picked Hono on Node 22 for the API layer because it is small, has first-class TypeScript, and runs unchanged on Node or on the edge. Remix sat in front for the patient-facing screens; the front-desk staff kept the old PHP UI until week four.
The first Hono service did exactly one thing: serve GET /patients/:id/consent from Postgres. Same JSON shape as the legacy endpoint. Byte-identical responses, verified by a diff harness that hit both backends with the same patient IDs and compared the bodies.
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)
})
Once the diff harness was green for ten consecutive days, we let Remix read from Hono. Writes still went through the old PHP. The clinic noticed nothing.
Step 4: Dual-write through a router
This is where most replatformings go wrong. The instinct is to flip writes in one go on a quiet Sunday. With an HL7 bus you cannot do that, because Rotterdam's lab does not have a quiet Sunday.
Instead, we put a router in front of every write endpoint. Each request was tagged with a routing key (the patient ID) and a percentage. At 5%, the router wrote to Postgres first, then to MySQL, then compared the two. At 25%, it wrote in parallel. At 100%, MySQL became the 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)
}
The divergence alert fired three times in week three. All three were the same bug: a Dutch postcode with a lowercase letter, which PHP normalised and Postgres did not. We added a citext cast and moved on.
Step 5: Replay HL7 onto a parallel Zorgmail listener
Zorgmail is the Dutch healthcare messaging backbone. The portal received HL7 v2.5 messages from the lab and acknowledged them with an MSH|...|ACK reply inside the 30-second timeout. If we missed an ACK, the lab queued and retried, and after three retries a human in Rotterdam got paged. That was the failure we could not have.
We did not move the Zorgmail endpoint. We stood up a second listener on a different subdomain, asked the lab to mirror messages to it for the cutover window, and replayed every message through both backends. The new listener wrote into Postgres and produced its own ACK, which we threw away. Only the legacy ACK reached the lab.
In week four, with the diff harness clean for nine days running, we asked the lab to swap the primary endpoint. The legacy listener kept running as the secondary for another three weeks.
Step 6: Cut traffic in five-percent steps
The patient-facing Remix app started at 5% of logins on a Monday morning. Postgres-routed users got a session flag, a cookie, and a small banner saying "you are testing the new portal, click here if anything looks wrong." Twelve people clicked. Nine had real bugs. One was angry about the new font.
We held at 5% for 48 hours, then ramped 5, 15, 35, 60, 100 across the next nine days. Each step required: zero new divergence alerts in the previous 24 hours, zero HL7 retries, zero NEN 7510 audit-log gaps. Any of those, we rolled back to the previous step inside ten minutes by flipping the router config.
Step 7: Audit the audit log
NEN 7510 is the Dutch healthcare information security standard, the local profile on top of ISO/IEC 27001. The auditor we worked with wanted three things: append-only storage, seven-year retention, and a chain of custody from the legacy audit_event table to whatever we replaced it with.
We did not replace it. We left the table name, the columns, and the row IDs identical. The new audit writer (a Hono middleware) inserted into Postgres using the same monotonically increasing integer the PHP code used, by reading the next ID from a sequence we seeded with MAX(id) + 1 from the legacy table at cutover. The auditor signed off on the migration in two hours. That was the cheapest hour of the project.
The day of the flip
The actual cutover took eleven minutes. Router config: 100% Postgres. DNS: pointed at the Remix edge. The PHP host went into read-only mode and stayed running for three weeks as a forensic copy. We sat on the call from 06:00 to 09:00 and watched the dashboards. The first patient booking through the new stack came in at 06:14, a 31-year-old woman re-confirming a follow-up appointment. We left the Slack channel open all day. The front-desk team rang once: they wanted the appointment-slot colours back. We changed them in 20 minutes.
What we would do differently
Three things.
We spent four days writing a custom CDC consumer before we admitted Debezium was the right call. Use Debezium.
We did the schema clean-up (utf8 to utf8mb4, dropping the 110 unused tables) inside the cutover window. Do it after. The new stack does not care about the dead tables.
We trusted the diff harness for nine days before flipping HL7. We should have trusted it after five. The marginal value of days six through nine was zero, and they cost us a weekend.
The closing
When we built the new patient portal for that Den Haag clinic, the hard part was not the framework choice. It was the order of the steps, and the willingness to leave the legacy stack running long after the new one was live. We do this kind of work under our legacy migration practice, mostly for healthcare, finance, and public-sector clients who cannot afford a maintenance window.
If you suspect you are sitting on a similar portal: open a terminal, run php -v on the host, and ask your auditor what would happen if that version disappeared in eight weeks. The answer is your roadmap.
Key takeaway
Cut writes in five-percent steps, mirror the HL7 bus through a second listener, and never flip a healthcare message bus in one go.
FAQ
Why Remix and Hono instead of one framework?
Remix renders the patient screens with progressive enhancement, which matters in a clinic on bad wifi. Hono handles the API and HL7 ingest with a smaller surface area than a full Node framework, and runs unchanged at the edge.
How do you keep NEN 7510 audit chain-of-custody during a migration?
Keep the audit table name, columns and row IDs identical, seed the new sequence with MAX(id)+1 from the legacy table at cutover, and run nightly hash diffs against the live source until the auditor signs off.
What does shadow traffic actually mean here?
It means the new stack receives a copy of real production requests, writes to its own database, and produces responses you discard. You compare them against the legacy responses until they match for a long enough window to trust the swap.
Can you do this without a message bus like Zorgmail in the picture?
The pattern is the same: dual-write, percentage router, diff harness, ramp in steps. The bus only changes one thing: never cut it in a single step, because the receiver does not know you are migrating.
How long should both stacks run in parallel after the flip?
Long enough to satisfy your auditor and your own nerves. For NEN 7510 we kept the legacy stack readable for three weeks post-flip and the audit data queryable for the full seven-year retention window.