← Blog

Joomla

Joomla 3.9 naar Directus + Remix: SCORM-cutover in 6 weken

Het is februari in Deventer. Een 14 jaar oud Joomla 3.9-portaal serveert 22.400 SCORM-lespakketten aan Nederlandse scholen. Zes weken later draait het op Directus en Remix.

Jacob Molkenboer· Oprichter · A Brand New Company· 24 mrt 2026· 11 min
Open leren grootboek, koperen sleutel, room kaart, groen lint en rood waszegel op ivoor papier bij raam.

Het is een dinsdagochtend in februari in Deventer. De product owner bij een onderwijsuitgever ziet haar staging-build voor de vierde keer deze sprint falen. Joomla 3.9 bereikte end-of-life in augustus 2023. PHP 7.3 ging er kort daarna achteraan. Het portaal dat 22.400 SCORM-1.2 lespakketten serveert aan scholen door heel Nederland draait nog op beide. Hun hostingpartij stuurt elke vrijdag een rustige mail met de vraag wanneer ze er iets aan gaan doen.

Dit is het verhaal van de zes weken daarna.

Het portaal dat we overnamen

Gebouwd in 2012. Joomla 3.9 met achttien extensies, vier daarvan custom en al jaren niet meer onderhouden. Een eigen PHP 7.3-laag voor SCORM-afspelen, voortgangsregistratie en de EduStandaard ECK-koppeling met Basispoort. Zo'n 4 GB MySQL-state — waaronder voortgangs-history per leerling die de uitgever wettelijk moet bewaren onder de UAVG-bewaartermijn. 22.400 lespakketten, variërend van een 40 KB HTML+JS-shell tot een wiskundemodule van 280 MB vol media.

De opdracht was niet 'maak het mooier'. De opdracht was: het mag nooit plat liggen tijdens een schoolweek. Punt.

Waarom Directus en Remix

Joomla's contentmodel gaat uit van pagina's met velden eraan vastgeschroefd. Het echte model van de uitgever lijkt meer op: pakket → module → asset → SCORM-manifest → leerlingvoortgang. Een headless CMS met relationele tabellen, dat is Directus. We hebben er al elf projecten op uitgeleverd en het row-level permission-model past zuiver op de school+leerling+rol-tuple van Basispoort.

We wogen ook Sanity en Payload af. GROQ van Sanity is elegant, maar het data-team van de uitgever rapporteert vanuit Metabase tegen ruwe SQL — daar wilden ze niet vanaf. Payload had Postgres of MongoDB onder de motorkap betekend; Directus draait netjes op MySQL, waardoor onze infrastructuur-footprint gelijk bleef aan wat het operations-team om 03:00 al monitorde.

Remix aan de frontend, omdat de bestaande PHP-routes SCORM-players invlochten in server-rendered pagina's met cookie-gebonden sessies. De nested routes en loaders van Remix maakten de één-op-één-port saai, en dat is wat je wil tijdens een migratie. Next.js hebben we niet serieus overwogen — de App Router was begin 2026 nog volop in beweging en we wilden een stabiele Remix v2.

Week één — inventariseren voor we code schrijven

De eerste week schreven we geen productiecode. We schreven een SCORM-auditor.

find /var/www/joomla/scorm -name imsmanifest.xml \
  | xargs -P 8 -n 1 ./bin/audit-scorm.js \
  > audit-2026-02-09.ndjson

De auditor liep door elke imsmanifest.xml, valideerde tegen de SCORM 1.2-XSD, resolvde elke <resource href> tegen het echte bestandssysteem en hashte elke asset. Hij markeerde ook elk manifest waarvan het <resources>-blok verwees naar een bestand dat de unpacker stilletjes had hernoemd — precies de faalmodus die Joomla-extensies graag achterlaten. De output was één NDJSON-regel per pakket, waardoor de rest van de week een shell pipeline werd in plaats van een JIRA-board.

Van de 22.400 pakketten:

  • 21.803 waren correct gevormde SCORM 1.2-manifests die we schoon konden parsen.
  • 412 hadden niet-ASCII-bestandsnamen in de ZIP die de oude PHP-unpacker stilletjes URL-encoded op disk had gezet. Ze werkten. En ze moesten blijven werken.
  • 147 verwezen naar absolute URL's die teruggingen naar de Joomla-installatie. Die zouden breken op het moment dat we het domein wisselden.
  • 38 waren corrupt, ergens tussen 2014 en 2016 geüpload en sindsdien nooit meer geopend.

De 38 corrupte pakketten werden hun eigen beslissing: we leverden de lijst aan de redacteur en lieten haar aankruisen welke begraven mochten worden. Drie kwamen terug als 'die hebben we toch nog nodig voor de kweekschool' en die hebben we opnieuw gebouwd vanuit de originele Word-bronnen. De andere 35 belandden in een legacy_burials-tabel met een grafsteen erbij.

Week twee — het Directus-datamodel

Directus-collecties, één-op-één met het domein:

-- simplified; foreign keys and timestamps omitted
pakketten         (id, ecknummer, titel, vakgebied, niveau, manifest_url)
pakket_assets     (id, pakket_id, path, size_bytes, mime, sha256)
leerlingen        (id, basispoort_eckid, school_id, geboortejaar)
voortgang         (id, leerling_id, pakket_id, sco_id,
                   status, score, suspend_data, last_seen)
voortgang_archive (id, ...same as voortgang...,
                   archived_at, retention_until)

Twee dingen die opvallen. Ten eerste staat voortgang_archive los van voortgang. De UAVG-bewaartermijn voor onderwijsvoortgang is twee jaar nadat de leerling de school verlaat. Actieve voortgang en compliance-voortgang hebben andere toegangsregels. Die in één tabel mixen is precies de beslissing waar je op dag 700 spijt van krijgt.

Ten tweede blijft suspend_data een TEXT-kolom. cmi.suspend_data uit SCORM 1.2 is een opaque blob van 4096 bytes die elke authoring tool anders codeert. Ga er niet slim mee om. Bewaar 'm letterlijk.

Daarnaast staat een migration_map-tabel die joomla_leerling_id ↔ directus_leerling_id en joomla_pakket_id ↔ directus_pakket_id koppelt, met een confidence-kolom voor rijen die we fuzzy joinden op geboortejaar plus schoolcode in plaats van ECK-iD. Ongeveer 200 van de 38.000 leerlingen vroegen om handmatige review; de redacteur werkte ze in een ochtend weg met een pagineerlijst en twee SQL-views.

Week drie — shadow traffic, geen blue-green

De interessante beslissing in week drie was om blue-green te laten vallen. Met blue-green flip je een load balancer en hoop je dat de nieuwe stack de echte load aankan. Wij hadden geen test-school die de canary wilde zijn.

In plaats daarvan draaiden we shadow traffic. Elke request naar het live Joomla-portaal werd gespiegeld naar de nieuwe Directus + Remix-stack op nieuw.[client].nl, waarbij alle writes naar een sandbox-schema gingen. Reads waren echt. Writes werden na de diff weggegooid.

De mirror leefde in nginx:

location /scorm/ {
  mirror /shadow;
  proxy_pass http://joomla_php_upstream;
}

location = /shadow {
  internal;
  proxy_pass http://remix_shadow_upstream$request_uri;
  proxy_set_header X-Shadow-Request "1";
}

Een Node-worker tailde vervolgens beide access logs en diffde drie dingen: HTTP-statuscodes, response body-hash voor de SCORM API-calls (LMSGetValue, LMSSetValue, LMSCommit) en response-latency p50/p95.

In week drie zagen we een diff-rate van 0,4%. Tegen week vijf was dat 0,02% — bijna volledig de 412 niet-ASCII-pakketten, die een aparte normalisatie-pass in de Remix-loader nodig hadden. Niets hiervan was opgekomen uit synthetische load-tests.

Een typische diff zag er zo uit. Een leerling opent module 6 van vmbo-2 wiskunde. De oude player stuurt LMSSetValue('cmi.core.lesson_status', 'completed') met een session cookie path van /scorm/. Remix sloeg het correct op, maar de diff-worker meldde een p95-latency van 280 ms aan de shadow-kant tegenover 85 ms op legacy. Dat was nginx die het mirror-request bufferde, niet de applicatie. We zetten proxy_buffering off op het mirror-pad en de shadow-latency landde binnen 40 ms van de oude stack. De klasse diffs die we zochten was content; de klasse die we steeds vonden was infrastructuurruis — wat op zichzelf nuttig is, want het vertelde ons waar de echte cutover pijn zou gaan doen.

Conclusie

Shadow traffic kost meer dan blue-green, maar het levert iets op wat blue-green niet kan: het bug-report schrijft zichzelf, in productie, terwijl het oude systeem nog echte gebruikers bedient.

Week vier — de Basispoort ECK-koppeling

Hier sterven de meeste migraties. Basispoort is de centrale authenticatie- en licentiedienst voor het Nederlandse primair en voortgezet onderwijs. De EduStandaard ECK-koppeling is een SAML-handshake plus een licentierechten-lookup — overzichtelijk in de theorie, pijnlijk in de praktijk.

De oude PHP-laag deed drie dingen in één endpoint: SAML-assertie-validatie, licentierechten-lookup en session bootstrap. We splitsten ze op in drie Remix-routes:

// app/routes/eck.saml.ts
export async function action({ request }: ActionFunctionArgs) {
  const assertion = await parseAndVerifySamlResponse(request, {
    metadataUrl: process.env.BASISPOORT_METADATA_URL!,
    clockSkew: 60,
  });

  const eckid = assertion.attributes['nlEduPersonProfileId'];
  if (!eckid) throw new Response('Missing ECK-iD', { status: 400 });

  return redirect(`/eck/licence?eckid=${encodeURIComponent(eckid)}`, {
    headers: { 'Set-Cookie': await samlSession.serialize({ eckid }) },
  });
}

We hielden clockSkew op 60 seconden omdat Basispoorts referentieservers driften, en een strakkere check faalde tijdens de testfase een keer of twee per uur. De oude site stond op 300 seconden; dat hebben we teruggebracht zodra we zeker wisten dat onze infrastructuur-klokken strak tegen NTP liepen.

Twee Basispoort-eigenaardigheden zijn het noemen waard. Ten eerste ondertekent de IdP de assertions maar niet de response-envelope; als je envelope-signing verplicht stelt, faalt ongeveer één op de drie handshakes, afhankelijk van welke referentie-instantie je raakt. Verifieer de assertion-handtekening en ga door. Ten tweede geeft de IdP nlEduPersonProfileId terug voor primair onderwijs maar nlEduPersonRealId voor delen van voortgezet onderwijs. Onze resolver leest beide en geeft de eerste voorrang; de oude code las er maar één en gaf stilletjes een 500 op de rest, en dat is precies waarom een handvol bovenbouwklassen de laatste twee jaar als hun docent inlogden.

Licentierechten kwamen terug als een lijst van ecknummer-waarden, die we direct joinden tegen de pakketten.ecknummer-kolom. Geen mappingtabel. Geen vertaallaag. Als de uitgever een pakket hernummert, geeft hij nieuwe ECK-nummers uit — dat is het contract.

Week vijf — voortgangsmigratie onder bewaartermijn-regels

4,1 miljoen voortgang-rijen. Daarvan hoorden 1,2 miljoen bij leerlingen die hun school al hadden verlaten. Onder de UAVG moesten ze nog twee jaar leesbaar blijven voor de uitgever, maar ze hoefden niet meer hot te staan.

De migratie liep in twee SQL-passes:

-- Active leerlingen: into the hot table
INSERT INTO directus.voortgang
  (leerling_id, pakket_id, sco_id, status, score, suspend_data, last_seen)
SELECT
  m.directus_leerling_id,
  m.directus_pakket_id,
  v.sco_id, v.status, v.score, v.suspend_data, v.last_seen
FROM joomla.voortgang v
JOIN migration_map m
  ON m.joomla_leerling_id = v.leerling_id
 AND m.joomla_pakket_id   = v.pakket_id
WHERE v.last_seen > NOW() - INTERVAL 2 YEAR;

-- Archive leerlingen: into the cold table, with retention stamps
INSERT INTO directus.voortgang_archive
  (..., archived_at, retention_until)
SELECT
  ..., NOW(), DATE_ADD(v.last_seen, INTERVAL 2 YEAR)
FROM joomla.voortgang v
WHERE v.last_seen <= NOW() - INTERVAL 2 YEAR;

Een cron draait elke nacht en dropt rijen waar retention_until < NOW(). De Functionaris Gegevensbescherming van de uitgever tekende het beleid af voordat we er een regel van schreven. Die handtekening staat in version control, naast de migratie.

Week zes — cutover, op de minuut

De cutover liep op een vrijdag in maart, 16:30 tot 17:45, nadat de schooldag was afgelopen.

16:30  Set Joomla to read-only via a plugin we wrote in week two.
16:32  Final voortgang delta sync. About 9,400 rows since 06:00.
16:38  DNS TTL on portaal.[client].nl already at 60s since Monday.
16:40  Flip DNS to the Remix edge.
16:43  First real LMSCommit lands on Directus. Worker confirms.
17:05  Smoke-test 40 random ECK-nummers via Basispoort acceptatie.
17:30  Disable shadow mirror in nginx.
17:45  Re-enable writes to legacy Joomla as a read-only fallback.

Om 18:00 zaten we op de dashboards. De eerste schoolmaandag piekte op 1.840 gelijktijdige leerlingen, ruim binnen waar de Remix-loaders en Directus-connection pool op gedimensioneerd waren. De oude stack bleef nog twee weken slapend liggen als rollback-doel en ging daarna in een bevroren archief.

Wat we verkeerd deden

Drie dingen, want de post-mortem is de post.

We onderschatten hoeveel de Joomla-session cookies via cross-origin reads in de SCORM-iframe lekten. De fix was een SameSite=None; Secure; Partitioned-cookie aan de Remix-kant, scope op de SCORM-player-route. We vingen het in week vier dankzij de shadow-diff, niet omdat we erover hadden nagedacht.

We over-engineerden de asset-CDN in week één. Een signed-URL-schema met 5 minuten expiry leek verstandig, totdat een docent een pakket via QR-code wilde inbouwen in een geprint werkblad. We versoepelden naar een token per school per dag. Kijk naar wat je echte gebruikers doen, niet naar wat je threat model denkt dat ze zouden moeten doen.

We vertrouwden te lang op de leerling-id-join. Ongeveer 600 leerlingen waren tussen exports door in Basispoort samengevoegd — dezelfde persoon, twee ECK-iD's door de jaren heen. De oude Joomla-code viel terug op een fuzzy match op geboortejaar plus schoolcode; onze schone Directus-join deed dat niet. De avond voor cutover voegden we een basispoort_id_aliases-tabel toe, waardoor de merge-cases maandagochtend oplichtten als dual-row-waarschuwingen in plaats van klachten over verdwenen voortgang. Niet pijnlijk, maar dat waren ze geweest als we niet hadden meegekeken.

Waar ABN hierin past

Toen we deze legacy-migratie uitleverden voor de onderwijsuitgever in Deventer, zat het zwaarste werk niet in de data. Het zat in de vier weken waarin de nieuwe stack shadow traffic bediende terwijl de oude nog authoritative was. Dat dubbele venster is wat je laat migreren zonder onderhoudsweekend, en het is precies het stuk dat de meeste teams overslaan.

Heb je een schoolweek te beschermen? Begin de audit voordat je een regel code schrijft. De 38 corrupte pakketten die we in week één vonden, waren in week acht naar boven gekomen als ouderklachten. Het kleinste wat je vandaag kunt doen: find . -name imsmanifest.xml | wc -l, lees er drie willekeurig en bevestig dat ze nog parsen.

Kern

Shadow traffic kost meer dan blue-green, maar je migreert er een levend schoolportaal mee zonder één enkel onderhoudsvenster.

FAQ

Waarom niet gewoon door op Joomla 4 of 5?

De custom PHP 7.3-laag voor SCORM en ECK was het echte kostenprobleem, niet Joomla zelf. Een Joomla-upgrade had de meest riskante code ongemoeid gelaten. Een schone overstap naar Directus zette beide tegelijk uit.

Waarom Directus en niet Strapi of Payload?

Directus spreekt SQL native, dus de leerling-voortgangstabellen blijven van buiten de CMS bevraagbaar. Strapi en Payload abstraheren de database op manieren die UAVG-bewaartermijn-audits en bulkrapportages compliceren.

Hoe ga je om met het suspend_data-veld van SCORM 1.2?

Behandel het als opaque tekst. Verschillende authoring tools coderen er andere dingen in. Probeer het nooit te parsen of te normaliseren. Kopieer het letterlijk van de oude naar de nieuwe LMS en laat de player het decoderen.

Kun je een schoolportaal migreren tijdens het schooljaar?

Ja, mits je minimaal vier weken shadow traffic draait en op een vrijdag na schooltijd cutovert. We hebben nog nooit een doordeweekse cutover gedaan voor een onderwijsklant en zouden dat ook niemand aanraden.

Hoe zit het met de Basispoort acceptatie-omgeving tijdens shadow traffic?

We gebruikten acceptatie alleen voor SAML round-trips en licentierechten. Echte ECK-iD's uit productie kwamen nooit op de shadow-stack terecht. De diff-worker vergeleek assertions structureel, niet op ECK-iD-waarde.

joomlaphpmysqllegacy sitesmigrationcase study

Iets bouwen?

Start een project