← Blog

Joomla

Joomla 3.4 naar Directus en Nuxt: shadow-cutover in zes weken

De IT-coördinator van een Tilburgse woningcorporatie heeft Joomla 3.4 in één tab open, de EOL-pagina van php.net in een ander, en een mail van de auditor in een derde. De vraag is kort.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2026· 9 min
Gesloten leren logboek met messing slot, sleutel op indexkaart, rode datumstempel, inktkussen, groen papieren label op ivoren vloei.

Het is dinsdagochtend in Tilburg. De IT-coördinator van de corporatie heeft een Joomla 3.4 admin open in één tab, de php.net-pagina voor PHP 7.2 in een tweede, en een mail van de auditor in een derde. Joomla 3.4 ging in maart 2016 end-of-life. PHP 7.2 volgde in november 2020. Het huurder-portaal dat erbovenop draait, bedient nog steeds 22.400 actieve huurcontracten, een onderhoudshistorie per woning die teruggaat tot 2011, en een nachtelijke SBR-Wonen-export die de Autoriteit Woningcorporaties op de eerste werkdag van elke maand verwacht. De Woningwet-bewaartermijn betekent dat er dertig jaar lang niets van weg mag.

Ze stuurt de mail naar ons door. De vraag bestaat uit twee woorden. Hoe snel?

Dit is de playbook die we de zes weken na die mail hebben gedraaid.

Het startpunt

Het systeem werd in 2011 gebouwd op Joomla 3.4 met een custom PHP-component, com_huurder, dat het contract- en onderhoudsmodel beheerde. MySQL 5.6. Een cron-job zette elke nacht om 02:00 het SBR-Wonen XBRL-pakket in elkaar en dropte het op het aansluitpunt. Sessions stonden in de database. Auth liep via Joomla's user-tabel met een erop geplakte TOTP-module.

Niets stond in brand. Alles lekte langzaam: geen security-updates op de Joomla-core, geen realistisch upgrade-pad op PHP omdat de helft van de component mysql_*-functies gebruikte en de andere helft uitging van PHP 5.6 type juggling. Pen-tests kwamen elk jaar terug met dezelfde vijf bevindingen.

De doelstack werd Directus voor de datalaag (PostgreSQL eronder, REST + GraphQL erbovenop, role-based permissions per collection) en Nuxt 3 voor de huurder-frontend. Allebei draaien ze op een Hetzner-cluster dat de corporatie al voor haar intranet gebruikte. Niets exotisch. Het doel was een stack waar ABN, de interne developer en de volgende aanwinst na hem allemaal in kunnen lezen zonder handleiding.

Waarom shadow-traffic en geen big-bang cutover

Het standaardadvies voor zo'n portaal is een vrijdagavondfreeze, een weekendmigratie en op maandagochtend hopen op het beste. Dat hebben we gedaan. Het werkt als het systeem klein is en de data schoon. Hier was geen van beide het geval.

Shadow-traffic — de nieuwe stack naast de oude draaien, echte requests spiegelen op de reverse proxy, responses vergelijken, maar alleen de response van het oude systeem aan de gebruiker teruggeven — koopt iets wat een freeze nooit kan: productieverkeer op de nieuwe stack, met echte huurder-accounts en echte contractnummers, weken voordat iemand er vertrouwen in heeft. Bugs komen boven onder een load-profiel dat je niet kunt synthetiseren.

De prijs is reëel. Je draait zes weken lang twee stacks. Je schrijft een diff-harness. Je bouwt een idempotency-verhaal voor het nieuwe systeem, zodat gespiegelde writes niks dubbel afrekenen. Voor een contractdragend portaal onder Woningwet-bewaartermijn vinden wij die afweging duidelijk.

Let op

Woningwet artikel 55a legt een bewaarvloer van 30 jaar op voor contract- en onderhoudsgegevens. Verwijder je tijdens de cutover een rij, dan kun je hem zes jaar later niet uit back-up reconstrueren. Behandel de legacy database vanaf de eerste dag als append-only, en snapshot hem voor elke schemawijziging.

Week 1 — Inventarisatie en de 30-jaarsvraag

De eerste week is geen code. Het zijn twee mensen in een kamer met de auditor, een geprinte ERD van het legacy-schema, en een lijst van elke cron-job, elke uitgaande cURL-call en elk IP dat het oude portaal vertrouwt.

We catalogiseerden 47 MySQL-tabellen, waarvan 14 rommel uit verlaten Joomla-extensies. 22.400 actieve huurcontracten, 31.800 historische. 1,4 miljoen onderhouds-events, de oudste van 2011-04-18. De SBR-Wonen-export, een XBRL-pakket dat het oude portaal in elkaar zette uit zes gejoinde queries. Achttien uitgaande integraties, de helft ongedocumenteerd. Twee bleken dood.

We legden met de auditor drie harde regels op papier vast. Eén: elke legacy-rij krijgt een stabiel, onveranderlijk legacy_id in het nieuwe systeem, zodat het audit-spoor over de migratiedag heen reconstrueerbaar blijft. Twee: de legacy MySQL blijft read-only en online voor de volledige bewaartermijn, op een eigen VLAN, als het canonieke archief. Drie: elke write in het nieuwe systeem die een contract of een onderhouds-event raakt, geeft een JSON-event af naar een append-only log, ondertekend met de sleutel van de operator.

Drie regels, allemaal saai, allemaal die meeting waard.

Week 2 — Directus-schema en contractmigratie

Directus heeft maar over één ding een uitgesproken mening: collections zijn tabellen, en de data is van jou. Dat paste ons. We modelleerden huurcontract, woning, huurder, onderhoud_event en sbr_export_run als first-class collections met expliciete foreign keys.

Het migratiescript draaide in twee passes. Pass één kopieerde de legacy MySQL met pgloader naar een staging-PostgreSQL, schema-gemapt maar verder letterlijk. Pass twee transformeerde staging naar de Directus-collections, waarbij elke rij zijn legacy_id en een migrated_at-timestamp meekreeg.

-- 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')
);

De source_hash was een SHA-256 van de canonieke JSON van de legacy-rij. Dat maakte de diff-harness in week 5 triviaal: een rij in PostgreSQL die niet matchte met zijn legacy-SHA was per definitie een migratiebug.

Week 3 — Nuxt-frontend en de auth-bridge

De frontend was de makkelijkste week, en dat verraste ons. Nuxt 3 met @sidebase/nuxt-auth, server routes die naar Directus proxyen, server-side rendering voor de contract-overzichtspagina's, client-side voor het onderhoudsformulier. Ongeveer 6.500 regels TypeScript en Vue, inclusief tests.

Het lastige was auth. Password hashes konden we niet migreren — Joomla's bcrypt-variant lag vóór PHP's password_hash en de salts stonden in een non-standaard veld. We wilden 22.400 huurders niet dwingen om op cutover-dag hun wachtwoord te resetten. Dus draaiden we beide auth-systemen een tijdje parallel.

De bridge: bij de eerste login via het Nuxt-portaal tijdens het shadow-window authenticeerde de gebruiker tegen de legacy Joomla auth-API. Als die akkoord ging, gaf Nuxt een nieuwe Directus-session uit, re-hashte het wachtwoord met Argon2id en zette een migrated_password_at-veld. Na de cutover ging de legacy auth-API uit, en werd iedere gebruiker zonder gemigreerd wachtwoord door een eenmalige reset-flow geleid.

Acht weken na cutover was 91% van de actieve huurders stil overgegaan. De resterende 9% kwam in de reset-flow terecht. We hoefden geen enkele huurder erover aan te schrijven.

Week 4 — De SBR-Wonen-pipeline herbouwen

De koppeling met het SBR-Wonen-aansluitpunt was het stuk dat niemand uit het legacy-team wilde aanraken. Het XBRL-pakket dat het oude portaal produceerde, werd in PHP in elkaar gezet met string-concatenatie. Het werkte, meestal. Het week ook af van de gepubliceerde taxonomie elke keer dat Aedes een update uitbracht, en elke afwijking was een brandje van 90 minuten.

We vervingen het door een kleine TypeScript-service die het XBRL rechtstreeks uit het taxonomie-schema bouwt, lokaal valideert, en pas daarna tekent en submit. De service draait in een container naast Directus. Hij trekt uit dezelfde PostgreSQL waar het portaal naar schrijft, dus de data is per constructie dezelfde data die de huurders zien.

// 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 draaiden de nieuwe pipeline drie maanden lang parallel met de oude na cutover. Elke export werd dubbel gegenereerd, en een kleine diff-job flagde elk veldverschil. We vonden twee echte drift-bugs en nul false positives. De historische bestanden die het Centraal Fonds Volkshuisvesting (nu de Autoriteit Woningcorporaties) onder de oude pipeline had ontvangen, werden als sanity check tegen de nieuwe gevalideerd voordat we de indienende identiteit omzetten.

Week 5 — Shadow-traffic op de reverse proxy

Dit is de week waar de playbook zijn naam aan dankt. We zetten nginx voor beide stacks en gebruikten mirror om live requests naar de nieuwe stack te kopiëren, terwijl de oude stack de response aan de gebruiker bleef geven.

# 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;
}

De nieuwe stack zag elke read. Writes waren een ander probleem — we konden niet toestaan dat gespiegelde writes het SBR-submission-endpoint dubbel raakten, of een onderhouds-event dubbel wegschreven. De oplossing was een header (X-Shadow: 1) die Directus en de SBR-service allebei op controller-niveau checkten. Onder die header gingen writes naar een shadow-schema in PostgreSQL, geïsoleerd van productiedata, en werden uitgaande calls gestubd.

Eind week 5 was de diff-rate op read-responses 0,04% — uitsluitend whitespace en één edge case in datumformaat. Goed genoeg.

Week 6 — Cutover en uitfaseren

De cutover was een config-wijziging. We zetten de nginx-upstream om naar Nuxt voor primair verkeer, hielden de legacy stack 14 dagen lang online en read-only als fallback, en keken toe. Geen huurder die het merkte.

De legacy Joomla draait vandaag de dag nog steeds, achter een firewall, zonder publieke route. Daar draait hij dertig jaar, in een kleine VM met snapshots naar glacier-tier storage en een geautomatiseerd jaarlijks auditor-rapport. Dat is de goedkoopste manier die we vonden om de bewaartermijn te respecteren zonder ooit nog in Joomla 3.4 te hoeven inloggen.

Wat we anders zouden doen

Twee dingen, achteraf.

We hebben de auth-bridge onderschat. Drie dagen werk werden er zeven, vooral omdat de legacy bcrypt-variant een kleine PHP-shim nodig had om te valideren, en wij koste wat het kost geen PHP in de nieuwe stack wilden draaien. We hadden die shim op dag één moeten accepteren.

We hebben de diff-harness over-engineered. In week 5 hadden we een volledige structurele JSON-diff met veldrapportage en Slack-alerts. Wat we daadwerkelijk gebruikten, was het aantal niet-matchende SHAs per uur. Goedkoop signaal, geen UI.

Er zit een breder patroon achter, eentje waar recente publicaties over het bouwen van betrouwbare agentic-AI-systemen steeds op terugkomen: de saaie infrastructuur — de diff-harness, het append-only log, de kill switch — is wat het interessante werk veilig maakt. Shadow-traffic is een idee uit de jaren zeventig. Het werkt nog steeds omdat de failure mode die het voorkomt — productie-only bugs in code die nooit onder echte load is getest — niet is verdwenen.

Wat ABN hier deed

Toen we dit voor de Tilburgse corporatie draaiden, was het hardste stuk de password-hash bridge tussen Joomla's bcrypt-variant en de Argon2id-sessions van Directus. We hebben uiteindelijk een PHP-validator van 200 regels geschreven die als kleine FastCGI-sidecar naast Nuxt leefde, en die hebben we uitgezet op de dag dat de laatste huurder overging. De rest van het werk — het schema, de shadow-proxy, de SBR-herschrijving — is het soort legacy-migratie dat we in een ritme van zes tot acht weken doen voor organisaties die zich geen weekendlange freeze kunnen veroorloven.

Wat je vandaag kunt doen

Als je een portaal draait dat ouder is dan je telefoon, open dan één terminal en draai php -v tegen de productiebak. Begint dat getal met een 7, dan heb je een auditprobleem en een securityprobleem, in die volgorde. Schrijf daarna de bewaartermijn op voor elke tabel die het portaal beheert, voordat je één regel code aanraakt. De migratie begint bij de retentieregels.

Kern

Shadow-traffic koopt je weken aan echte productieload op de nieuwe stack voordat een enkele huurder ervan afhangt. Voor contractdragende portalen is die afweging duidelijk.

FAQ

Waarom shadow-traffic in plaats van een weekendfreeze en cutover?

Een freeze geeft je één kans om bugs te vinden. Shadow-traffic draait de nieuwe stack weken lang tegen echte productieload terwijl het oude systeem nog gebruikers bedient, dus bugs komen boven voordat een huurder afhankelijk wordt van de nieuwe code.

Hoe respecteer je de 30-jarige Woningwet-bewaartermijn zonder Joomla 3.4 gepatched te houden?

Zet de legacy database in een gefirewallde VM zonder publieke route, snapshot hem naar glacier-tier storage en genereer een geautomatiseerd jaarlijks auditor-rapport. Een read-only legacy blijft compliant zonder dat iemand ooit nog hoeft in te loggen op het oude CMS.

Moesten huurders hun wachtwoord resetten op de cutover-dag?

Nee. Tijdens het shadow-window valideerde het nieuwe portaal bij de eerste login tegen de legacy Joomla auth-API en re-hashte vervolgens stil met Argon2id. 91% van de actieve huurders ging transparant over. De rest kwam na cutover in een eenmalige reset-flow terecht.

Hoe lang duurde het om de SBR-Wonen-pipeline te herbouwen?

Eén week bouwen, drie maanden beide pipelines parallel draaien en veld voor veld diffen voordat de nieuwe autoritatief werd. Twee echte taxonomie-drift-bugs kwamen boven, nul false positives.

joomlamigrationlegacy sitesphpmysqlcase study

Iets bouwen?

Start een project