PHP
PHP 7.2 to Remix: a seven-week shadow-traffic cutover
A 36-person coatings distributor in Mechelen runs its product catalogue on Symfony 2.8. Here is the seven-week shadow-traffic cutover we used to retire it without breaking a single SDS link.

It is Friday in Mechelen. The warehouse manager has just signed off the last pallet of two-component epoxy for a shipyard in Vlissingen, and behind him a 13-year-old catalogue silently fires its nightly artikelsync against AFAS Profit at 23:50. The catalogue runs on PHP 7.2 and Symfony 2.8. Both have been out of support for years. Nobody on the team wants to be the person who breaks the 22,000 REACH-fiches at three in the morning.
This is the seven-week playbook for moving that catalogue onto Remix, Hono and Postgres using shadow traffic, written from the cutover we just shipped for a 36-person industrial-coatings distributor.
The starting state
The distributor sells primers, two-component systems and specialty coatings to shipyards, bridge contractors and steel fabricators across Belgium, the Netherlands and northern France. The catalogue is the system of record for four things at once:
- 22,000 REACH-fiches with hazard pictograms, UN numbers and exposure notes
- A nightly artikelsync from AFAS Profit that creates and prices stock-keeping units
- A long redirect history of SDS PDF URLs that are deep-linked from regulator portals, customer ERPs and competitor comparison sites
- The B2B order flow for roughly 1,400 active accounts
The framework underneath is end-of-life. PHP 7.2 stopped receiving security fixes on 30 November 2020, and Symfony 2.8 went out of long-term support a year earlier. The composer.lock had not been touched since 2019. The MySQL 5.7 database was about to do the same.
The brief from the CFO was short. Modernise the stack. Do not let SDS PDFs 404. Do not break the nightly AFAS sync. Do not give the auditor anything to write about.
Why shadow traffic and not a big-bang cutover
Industrial chemicals deep-link more than almost any other B2B catalogue we have rebuilt. REACH obliges suppliers to make safety data sheets available, and customers wire those URLs straight into their procurement systems. A 404 on an SDS path is not a soft 404. It is a phone call from a compliance officer.
Big-bang cutovers fail two ways in this shape of system. Either the new stack has a bug nobody noticed because the old one ran in parallel for ten minutes, or the rollback is so painful that the team rolls forward through bad data because they cannot face going back. Shadow traffic lets you serve the old system to real users while the new one runs the same requests in the dark. You measure the gap before anyone gets hurt.
Week 1, reading the old catalogue
We do not refactor what we do not understand. Week one was three engineers and the client's lead developer reading the Symfony 2.8 codebase together for four days. By Friday we had three artefacts:
- A schema diagram of all 64 MySQL tables, with foreign keys that existed in code but not in the database
- A flat list of every route the application served, tagged HTML, JSON, PDF or redirect
- A list of every external system that touched the catalogue, including the AFAS GetConnector, two customer EDI pipelines and one Excel macro maintained by the warehouse manager since 2014
The Excel macro was the surprise. It pulled a CSV through the public catalogue every Tuesday at 06:30, parsed it with a regex, and printed pick-lists. Nobody had documented it. Shadow traffic caught it in week five because the User-Agent never lied.
Week 2, the Postgres schema and the first import
We did not carry MySQL drift into Postgres. The new schema was hand-written, with proper foreign keys, check constraints on the REACH status column, and a generated tsvector for full-text search across Dutch, French and English descriptions.
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 had been hiding three columns from the codebase since 2018. The old MySQL stored them as nullable VARCHAR(255) and the application happily wrote nothing to them. We turned two of them into proper enums and dropped the third. One of the enums turned out to be load-bearing in a Power BI report nobody told us about; we found out in week five and patched the report query the same afternoon.
The artikelsync moved from a Symfony console command pulling SOAP responses into Doctrine entities to a small Node worker reading AFAS GetConnector output, normalising it to TSV, and streaming it into Postgres with COPY. The whole nightly job dropped from 47 minutes to under four.
Week 3, the Hono API behind a flag
We built the new read API in Hono. The response shapes copied the old Symfony controllers byte for byte, including a handful of typos in JSON keys that customer integrations depended on. Tests were generated from a week of production 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
Behind a feature flag the new endpoints answered for shadow callers only. Nothing in production routed to Hono yet.
Week 4, the Remix frontend
Remix was the right shape for a catalogue. Forms post to the same URL they render from, loaders fetch data once on the server, and progressive enhancement covers the customers still on Edge inside a paint factory. We rebuilt 14 routes in week four: product page, category page, search, login, basket, the five checkout steps, account, order history, the SDS viewer, and the static-content fallback.
One Nginx rule fronted both stacks. A request carrying X-ABN-Shadow: 1 went to Remix; everything else still went to Symfony. The Remix app was reachable on the production domain, on production data, with zero risk to real customers.
Week 5, shadow traffic and HTML diffing
This is where the migration earns its keep. We forked 100% of read traffic at the edge. Every request hit Symfony (the source of truth) and Remix (the candidate) in parallel. Only the Symfony response went back to the browser. The Remix response was canonicalised, compared, and logged.
// 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
Canonicalisation is where shadow-diff projects quietly die. The naive version compares raw bytes, reports a 6% diff on day one, the team gives up by Friday. The honest version is a list of every source of legitimate variance you can think of, stripped on both sides before comparison. Ours stripped CSRF tokens, request IDs, ISO timestamps within five seconds of each other, the Symfony debug nonce, two cookie-based personalisation strings, and a stable sort applied to any DOM node that wrapped a list rendered in non-deterministic order. Anything we did not canonicalise came back as a false-positive diff and buried the real ones.
By day three of week five we were at 99.4% byte-identical canonical HTML across roughly 180,000 daily requests, and a false-positive rate of 0.04%. The remaining real 0.6% was three classes of bug: a wrong locale fallback for 41 French descriptions, a missing hazard pictogram for one class of polyurethane primer, and the Excel macro from week one expecting tabs where Remix sent two spaces. Each one took less than half a day to fix because the diff log told us exactly which URL, which DOM node, and which input row.
Week 6, the SDS PDF redirect chain
For 13 years the company had moved SDS PDFs around. A 2015 PDF lived at /sds/EPX2.pdf. The same product is today at /assets/sds/2024-06/epx2-v3.pdf. Customer ERPs and competitor comparison sites still link to the 2015 path. Some link to four intermediate paths that nobody on the current team remembers shipping.
We wrote a 1,412-line redirect map. Each entry was a deep-checked source URL, current target, last-verified date, and a count of unique referrers from the last 90 days of access logs. Then we served it from 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)
})
If you reply 404 to a regulator's deep link, the customer's compliance officer emails your CEO before lunch. Use 410 Gone for revoked PDFs. Reserve 404 for URLs you never owned.
Week 7, the cutover and the rollback path
By Monday we had three weeks of clean shadow diff. The cutover was Nginx weight changes. Read traffic moved to Remix at 10% on Tuesday morning, 50% Tuesday afternoon, 100% by Wednesday lunch. Admin and the AFAS artikelsync moved on Friday after the warehouse closed. We left the Symfony stack warm for two more weeks as a 12-second rollback.
During the 10/50/100 ramp the team watched four dashboards on one wall: HTTP status codes per route, SDS PDF redirect hit-rate, AFAS sync record count and lag, and a synthetic checkout that placed a one-cent test order every 90 seconds. The rollback triggers were written down in advance, in plain language, and signed off by the CFO: any 5xx rate above 0.3% sustained for five minutes, any SDS 410 above its 90-day baseline, or any divergence in the synthetic checkout total. The point of writing them down was not the thresholds. It was so that nobody had to make a judgement call at 11pm on a Tuesday.
We never used the rollback. The CFO slept on cutover Friday for the first time in a year.
What we would not do again
Four things, in the order they hurt.
First, we carried six small bugs forward because they were identical in both systems. Two of them turned out to be load-bearing in customer integration tests. Fix them in shadow, not after.
Second, we underestimated the SDS redirect mapping by a factor of three. Plan for 1,000+ entries, not 300. The access log is your only source of truth.
Third, we let the AFAS artikelsync run against both databases on cutover night, deliberately, and did not budget for it. The overnight CPU bill doubled. It was worth it for the diff confidence, but tell your finance person first.
Fourth, we treated the warehouse manager's Excel macro as a curiosity rather than a stakeholder. He had been parsing that CSV every Tuesday at 06:30 since 2014 and he knew the catalogue better than the code did. We should have invited him to the week-five diff review. He would have spotted the tab-vs-space issue on day one and saved us a Thursday.
When we ran this for the Mechelen distributor, the part that almost cost us was the regulator-side deep linking, not the framework upgrade. The Hono redirect map is now the first artefact we ship in any legacy migration we take on. If your stack looks like theirs did in 2019, the order of operations matters more than the target framework.
If you want to start somewhere today, open last week's access log on your old catalogue. Grep for /sds/, or whatever your equivalent regulator-facing path is. Count the unique paths. That number, multiplied by three, is your week-six budget.
Key takeaway
A PHP catalogue migration is mostly about URLs and the data sync. The framework swap is the easy part.
FAQ
Why shadow traffic instead of a big-bang cutover?
Shadow traffic lets the new system answer real requests in parallel while the old one still serves users. You measure the gap before anyone gets hurt, and the rollback path stays at one Nginx config swap.
Why Hono and not Express or Fastify?
Hono runs the same handler code at the edge and in Node, which let us shadow-deploy at the Cloudflare layer before promoting it to the origin. Express or Fastify would have been fine in the origin only.
What happens to old SDS PDF URLs after migration?
Every historical SDS path is mapped to its current target via a Hono redirect handler. Live PDFs return 301. Withdrawn ones return 410 Gone, never 404, so regulator deep links never break silently.
How long does a similar PHP catalogue migration take?
Seven weeks for a 22,000-product catalogue with a single ERP sync and one regulatory redirect chain. Add roughly one week per additional integration. Most of the time goes into shadow diffing, not framework code.