Joomla
Joomla 3.4 to Directus and Nuxt: a six-week shadow cutover
A Tilburg woningcorporatie's IT-coordinator has Joomla 3.4 open in one tab, php.net's EOL page in another, and an auditor's email in a third. The question is two words long.

It's Tuesday morning in Tilburg. The corporatie's IT-coordinator has a Joomla 3.4 admin open in one tab, php.net's PHP 7.2 page in another, and an email from the auditor in a third. Joomla 3.4 reached end of life in March 2016. PHP 7.2 followed in November 2020. The huurder-portaal that runs on top of them still serves 22,400 active rental contracts, an onderhouds-history per woning that goes back to 2011, and a nightly SBR-Wonen export the Autoriteit Woningcorporaties expects on the first working day of every month. The Woningwet bewaartermijn means none of it can be thrown away for thirty years.
She forwards the email to us. The question is two words long. How fast?
This is the playbook we ran for six weeks after that email.
The starting state
The system was built in 2011 on Joomla 3.4 with a custom PHP component, com_huurder, that owned the contract and maintenance models. MySQL 5.6. A cron job assembled the SBR-Wonen XBRL bundle every night at 02:00 and dropped it on the aansluitpunt. Sessions lived in the database. Auth was handled by Joomla's user table plus a stamped-on TOTP module.
Nothing was on fire. Everything was a slow leak: no security updates on the Joomla core, no realistic upgrade path on PHP because half the component used mysql_* functions and the other half assumed PHP 5.6 type juggling. Pen-tests came back with the same five findings every year.
The destination stack was Directus for the data layer (PostgreSQL underneath, REST + GraphQL on top, role-based permissions per collection) and Nuxt 3 for the tenant-facing frontend. Both run on a Hetzner cluster the corporatie already had for their intranet. Nothing exotic. The point was to land on a stack that ABN, the in-house developer, and the next hire after him could all read without a manual.
Why shadow traffic, not a big-bang cutover
Standard advice for a portal like this is a Friday-evening freeze, a weekend migration, and a Monday-morning hope. We've done that. It works when the system is small and the data is clean. This was neither.
Shadow traffic — running the new stack alongside the old, mirroring real requests at the reverse proxy, comparing responses, but only ever returning the old system's response to the user — buys you something a freeze never can: production traffic on the new stack, with real tenant accounts and real contract numbers, for weeks before anyone trusts it. Bugs surface against a load profile you cannot synthesize.
The cost is real. You run two stacks for six weeks. You write a diff harness. You build an idempotency story for the new system so mirrored writes don't double-charge anything. For a contract-bearing portal under woningwet retention, we think the trade is obvious.
Woningwet article 55a sets a 30-year retention floor on contract and maintenance records. If you delete a row in the cutover you cannot reconstruct it from backup six years later. Treat the legacy database as append-only from the day you start, and snapshot it before every schema change.
Week 1 — Inventory and the 30-year question
The first week is not code. It is two people in a room with the auditor, a printed ERD of the legacy schema, and a list of every cron job, every outbound cURL call, and every IP the old portal trusts.
We catalogued 47 MySQL tables, 14 of them junk from abandoned Joomla extensions. 22,400 active huurcontracten, 31,800 historical ones. 1.4 million onderhouds-events, the oldest dated 2011-04-18. The SBR-Wonen export, an XBRL bundle the legacy portal built from six joined queries. Eighteen outbound integrations, half undocumented. Two of them turned out to be dead.
We agreed three non-negotiables with the auditor in writing. One: every legacy row gets a stable, immutable legacy_id in the new system, so the audit trail across migration day is reconstructible. Two: the legacy MySQL stays read-only and online for the full bewaartermijn, on its own VLAN, as the canonical archive. Three: every write in the new system that touches a contract or a maintenance event emits a JSON event to an append-only log, signed with the operator's key.
Three rules, all boring, all worth the meeting.
Week 2 — Directus schema and the contract migration
Directus is opinionated about one thing: collections are tables, and the data is yours. That suited us. We modelled huurcontract, woning, huurder, onderhoud_event and sbr_export_run as first-class collections with explicit foreign keys.
The migration script ran in two passes. Pass one copied the legacy MySQL into a staging PostgreSQL using pgloader, schema-mapped but otherwise verbatim. Pass two transformed staging into the Directus collections, with every row carrying its legacy_id and a migrated_at timestamp.
-- The shape we used for huurcontract. Note the legacy_id and the
-- check constraint on bewaar_tot: Postgres refuses to insert anything
-- that would expire before the woningwet floor.
CREATE TABLE huurcontract (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id bigint UNIQUE NOT NULL,
woning_id uuid NOT NULL REFERENCES woning(id),
huurder_id uuid NOT NULL REFERENCES huurder(id),
ingangsdatum date NOT NULL,
einddatum date,
bewaar_tot date NOT NULL,
migrated_at timestamptz NOT NULL DEFAULT now(),
source_hash bytea NOT NULL,
CONSTRAINT bewaartermijn_floor
CHECK (bewaar_tot >= COALESCE(einddatum, ingangsdatum) + INTERVAL '30 years')
);
The source_hash was a SHA-256 of the legacy row's canonical JSON. It made the diff harness in week 5 trivial: a row in PostgreSQL that did not match its legacy SHA was, by definition, a migration bug.
Week 3 — Nuxt frontend and the auth bridge
The frontend was the easiest week, which surprised us. Nuxt 3 with @sidebase/nuxt-auth, server routes proxying to Directus, server-side rendering for the contract overview pages, client-side for the maintenance request form. About 6,500 lines of TypeScript and Vue, including tests.
The hard part was auth. We could not migrate password hashes — Joomla's bcrypt variant predated PHP's password_hash and the salts were stored in a non-standard field. We did not want to force 22,400 tenants to reset their password on cutover day. So we ran both auth systems in parallel for a window.
The bridge: on the first login through the Nuxt portal during the shadow window, the user authenticated against the legacy Joomla auth API. If it accepted them, Nuxt issued a new Directus session, re-hashed the password with Argon2id, and stamped a migrated_password_at field. After cutover, the legacy auth API was disabled and any user without a migrated password was funnelled into a one-time reset flow.
Eight weeks after cutover, 91% of active tenants had migrated silently. The remaining 9% hit the reset flow. Nobody had to write to a tenant about it.
Week 4 — Rebuilding the SBR-Wonen pipeline
The koppeling to the SBR-Wonen aansluitpunt was the part nobody on the legacy team wanted to touch. The XBRL bundle the old portal produced was assembled in PHP using string concatenation. It worked, mostly. It also drifted from the published taxonomy every time Aedes shipped an update, and every drift was a 90-minute fire-drill.
We replaced it with a small TypeScript service that builds the XBRL from the taxonomy schema directly, validates locally, and only then signs and submits. The service runs in a container next to Directus. It pulls from the same PostgreSQL the portal writes to, so the data is by construction the same data the tenants see.
// The whole bundle assembly fits in one function. The taxonomy version
// comes from the .xsd we vendor in the repo — bumping it is a PR, not
// a midnight string-fix.
import { buildBundle, signSbrEnvelope } from "./sbr"
import { fetchMonthlyAggregates } from "./db"
export async function runSbrExport(period: string) {
const data = await fetchMonthlyAggregates(period)
const bundle = buildBundle({
taxonomyVersion: "sbr-wonen-2026.1",
period,
data,
})
await bundle.validateLocally() // throws on taxonomy drift
const envelope = await signSbrEnvelope(bundle, process.env.SBR_KEY!)
return envelope.submit()
}
We ran the new pipeline in parallel with the legacy one for three months after cutover. Every export was generated twice, and a small diff job flagged any field-level difference. We caught two real drift bugs and zero false positives. The historical filings the Centraal Fonds Volkshuisvesting (now the Autoriteit Woningcorporaties) had received under the old pipeline were re-validated against the new one as a sanity check before we switched the submitting identity.
Week 5 — Shadow traffic at the reverse proxy
This is the week that earns the playbook its name. We put nginx in front of both stacks and used mirror to copy live requests to the new stack while returning the old stack's response to the user.
# Legacy stays authoritative. Every request is mirrored to /shadow,
# which proxies to Nuxt. The diff harness logs response deltas
# without ever blocking the tenant's actual request.
location / {
mirror /shadow;
mirror_request_body on;
proxy_pass http://legacy_joomla;
}
location = /shadow {
internal;
proxy_pass http://nuxt_portal$request_uri;
proxy_set_header X-Shadow "1";
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
The new stack saw every read. Writes were a different problem — we could not let mirrored writes hit the SBR submission endpoint twice, or write a maintenance event twice. The solution was a header (X-Shadow: 1) that Directus and the SBR service both checked at the controller level. Under that header, writes ran to a shadow schema in PostgreSQL, isolated from production data, and outbound calls were stubbed.
By the end of week 5 the diff rate on read responses was 0.04% — entirely whitespace and a single date-format edge case. Good enough.
Week 6 — Cutover and sunsetting
Cutover was a config change. We flipped the nginx upstream to point primary traffic at Nuxt, kept the legacy stack online and read-only as a fallback for 14 days, and watched. No tenant noticed.
The legacy Joomla still runs today, behind a firewall, with no public route. It will run there for thirty years, in a small VM with snapshots to glacier-tier storage and an automated yearly auditor report. That is the cheapest way we found to honour bewaartermijn without ever logging into Joomla 3.4 again.
What we'd do differently
Two things, with hindsight.
We underestimated the auth bridge. Three days of work turned into seven, mostly because the legacy bcrypt variant needed a tiny PHP shim to validate, and we kept trying to avoid running any PHP at all in the new stack. We should have accepted the shim on day one.
We over-engineered the diff harness. By week 5 we had a full structural JSON-diff with field-level reporting and Slack alerts. What we actually used was the count of non-matching SHAs per hour. Cheap signal, no UI.
There is a broader pattern that recent writing on building reliable agentic AI systems keeps circling back to: the boring infrastructure — the diff harness, the append-only log, the kill switch — is what makes the interesting work safe. Shadow traffic is a 1970s idea. It still works because the failure mode it prevents — production-only bugs in code nobody has stress-tested against real load — has not gone away.
What ABN did here
When we ran this for the Tilburg corporatie, the thing we hit hardest was the password-hash bridge between Joomla's bcrypt variant and Directus' Argon2id sessions. We ended up writing a 200-line PHP validator that lived next to Nuxt as a small FastCGI sidecar and retired it the day the last tenant migrated. The rest of the work — the schema, the shadow proxy, the SBR rewrite — is the kind of legacy migration we do on a six-to-eight-week cadence for organisations that cannot afford a weekend-long freeze.
What to do today
If you run a portal that's older than your phone, open one terminal and run php -v against the production box. If the number starts with 7, you have an audit problem and a security problem, in that order. Then write down the bewaartermijn for every table the portal owns before you touch any code. The migration starts with the retention rules.
Key takeaway
Shadow traffic buys you weeks of real production load on the new stack before a single tenant depends on it. For contract-bearing portals, that trade is obvious.
FAQ
Why shadow traffic instead of a weekend freeze and cutover?
A freeze gives you one chance to find bugs. Shadow traffic runs the new stack against real production load for weeks while the old system still serves users, so bugs surface before any tenant depends on the new code.
How do you honour the 30-year woningwet bewaartermijn without keeping Joomla 3.4 patched?
Pull the legacy database into a firewalled VM with no public route, snapshot it to glacier-tier storage, and generate an automated yearly auditor report. Read-only legacy stays compliant without anyone ever logging into the old CMS.
Did tenants have to reset their passwords on cutover day?
No. During the shadow window the new portal validated against the legacy Joomla auth API on first login, then silently re-hashed with Argon2id. 91% of active tenants migrated transparently. The rest hit a one-time reset flow after cutover.
How long did the SBR-Wonen pipeline take to rebuild?
One week of build, three months of running both pipelines in parallel and diffing field-by-field before the new one became authoritative. Two real taxonomy-drift bugs surfaced, zero false positives.