Drupal
Van Drupal 7 naar SvelteKit: zorgportaal in zes weken
Een zorggroep van 23 mensen in Alkmaar had een Drupal 7-portaal, 32.800 zorgplan-PDF's en een HL7-feed naar ChipSoft HiX. Dit is de cutover van zes weken, stap voor stap.

De dinsdag dat de waarschuwing binnenkwam
Het was een dinsdag in februari. De operations lead van een zorggroep van 23 mensen in Alkmaar opende haar inbox en vond een melding van haar hostingpartner: de gedeelde PHP 7.2-server waar haar cliëntenportaal sinds 2013 op draaide, werd over acht weken uitgefaseerd. Drupal 7 had sinds januari 2025 geen officiële support meer. Het portaal was het dagelijkse thuis van 1.400 cliënten, hun familieleden en het team van 23 mensen dat hun zorg coördineerde. Het was waar zorgplannen werden ondertekend, waar machtigingen werden vastgelegd onder de Wgbo, en waar nieuwe opnames stilletjes binnenkwamen uit ChipSoft HiX via een HL7v2 ADT-feed die niemand in het huidige team had ingericht.
Ze belde ons woensdagochtend.
Dit is het verhaal van zes weken om dat portaal over te zetten. De basis van het plan geldt voor elke Drupal 7-site met een echte database erachter: stuur shadow-traffic over beide stacks, migreer op gedrag in plaats van op feature, en maak van cutover-dag een non-event.
Waarom we Drupal niet eerst hebben geüpgraded
Het instinct zegt: van Drupal 7 naar Drupal 10 en dat heet dan een migratie. We hebben er ongeveer een uur over nagedacht.
Het portaal had 47 custom modules. Twaalf daarvan hadden één auteur die in 2017 was vertrokken. De PDF-rendering liep via een geforkte PHP-library die niet meer werkte boven PHP 7.4. De ChipSoft HiX-feed zat erin geplakt met een custom module die elke twee minuten een SFTP-drop pollde en HL7-segmenten parste met regex. Dat migreren naar een moderne Drupal-release had betekend dat we de modules sowieso moesten herschrijven, met de Drupal upgrade-tax er bovenop, en eindigen op een stack waar niemand in het team van de zorggroep mee uit de voeten kon.
We kozen Sanity voor het contentmodel en SvelteKit voor het portaal omdat het team twee parttime developers had die al JavaScript kenden en de codebase zes maanden na ons vertrek konden lezen. Drupal-expertise in de regio Alkmaar, voor een budget dat een zorggroep van 23 mensen kan betalen, is een krimpende markt. SvelteKit-kennis niet.
Het shadow-traffic-plan van zes weken
Dit is het schema dat we daadwerkelijk gedraaid hebben.
Week 1 ──── discovery, read-only mirror, schemamapping
Week 2 ─┐
Week 3 ─┴── nieuwe stack bouwen, contentmodel, auth, PDFs
Week 4 ──── shadow traffic, response-diffing
Week 5 ──── weighted rollout: 5% → 20% → 50%
Week 6 ──── DNS-cutover, 7 dagen hot standbyWeek 4 was de dragende week. Elk request dat het oude portaal raakte, werd opnieuw afgespeeld tegen het nieuwe portaal, headers eraf, en de responses werden gediffed door een kleine worker die de verschillen logde. Week 5 was een trage weighted rollout. Week 6 was de cutover en de zeven dagen hot-standby van de oude stack.
De reden dat dit werkt voor een portaal van deze omvang: je hoeft niet slim te zijn. Je hebt een diff-tool nodig en het geduld om naar elke afwijkende response te kijken.
32.800 zorgplan-PDF's verhuizen zonder links te breken
De PDF's waren het onderdeel waar iedereen zich zorgen om maakte en het onderdeel dat uiteindelijk hanteerbaar bleek.
Elke zorgplan-PDF werd vanuit een node in Drupal gerefereerd, maar de URL waar de cliënt daadwerkelijk vanuit haar inbox op klikte, had de vorm /zorgplan/download/{nid}/{filehash}. Die URL stond in meer dan 17.000 gearchiveerde e-mails die teruggingen tot 2014. Hem breken was geen optie.
We deden drie dingen.
Eerst exporteerden we de file-map uit Drupals file_managed-tabel, gejoind tegen de field_data_field_zorgplan_pdf-tabel. Dat gaf ons 32.800 rijen met (nid, filehash, storage path, mime, size). We rsyncten de bestanden naar een S3-compatibele bucket en verifieerden via sha256.
-- de join-query die we tegen de oude Drupal-database draaiden
SELECT
fm.fid,
fm.uri,
fm.filename,
fm.filemime,
fm.filesize,
fdz.entity_id AS nid,
SUBSTRING_INDEX(
SUBSTRING_INDEX(fm.uri, '/', -1),
'.', 1
) AS filehash
FROM file_managed fm
JOIN field_data_field_zorgplan_pdf fdz
ON fdz.field_zorgplan_pdf_fid = fm.fid
WHERE fm.status = 1;Ten tweede bouwden we één SvelteKit-endpoint op het legacy URL-patroon, dat het (nid, filehash)-paar uitlas, het opzocht in een Postgres-tabel die uit de export was gevuld, de sessie van de cliënt controleerde, en het bestand vanuit object storage streamde. Het endpoint logde elke hit met de originele Drupal-nid zodat we de long tail konden volgen.
Ten derde droeg elk zorgplan-document in het nieuwe Sanity-contentmodel zowel zijn nieuwe ID als zijn legacy Drupal-nid. De portaal-UI linkte naar de nieuwe URL. De oude URL bleef werken voor inbox-archeologie.
Twee weken na de cutover kwam 4% van de PDF-downloads nog binnen op de legacy URL. Zes maanden na de cutover is dat 1,1%. We laten het endpoint voor onbepaalde tijd staan.
Machtigings-historie onder de Wgbo
De Wgbo (Wet op de geneeskundige behandelingsovereenkomst) verplicht dat toestemming voor medische beslissingen wordt vastgelegd, herleidbaar is, en op verzoek van de cliënt of diens vertegenwoordiger terug te halen is. In het oude portaal stond die historie op twee plekken: een node_revision-rij per toestemmingswijziging, en een custom machtigingen_log-tabel die vastlegde wie het vinkje zette, vanaf welk IP, op welk tijdstip, en welke versie van de toestemmingstekst diegene te zien kreeg.
Op zo'n systeem kun je geen revisies weggooien. Een toestemming uit 2019 voor een behandelplan dat één ding zei, is niet hetzelfde als een toestemming uit 2023 voor een plan dat iets anders zegt, en bij een geschil over drie jaar moeten beide versies in het dossier zitten.
We modelleerden dit in Sanity als een append-only machtigingen-event-documenttype, met een referentie terug naar de cliënt, een gedenormaliseerde kopie van de toestemmingstekst zoals die op dat moment te zien was, de actor (cliënt, vertegenwoordiger of zorgmedewerker), de bron-controller in het portaal, het IP en de user-agent, en het ISO-tijdstempel. Het migratiescript liep door de oude machtigingen_log-tabel en speelde elke rij opnieuw af in Sanity als een nieuw event-document, met _createdAt ingesteld op het oorspronkelijke tijdstempel.
Sanity zet _createdAt standaard op het moment van inlezen. Als je dat bij een bulk-import niet expliciet zet, klapt je hele Wgbo-audit trail in elkaar tot de datum van de migratie. We vingen dit op in de dry run. Test het voordat je de import op productie draait.
We bewaarden ook een woordelijke CSV-export van de oude logtabel, ondertekend en gedateerd, in cold storage. Als een rechtbank ooit om het spoor van vóór de migratie vraagt, geven we ze het origineel.
De HL7v2 ADT-feed naar ChipSoft HiX
Dit was het deel waarvan we 's nachts wakker lagen.
ChipSoft HiX is het dominante ziekenhuisinformatiesysteem in Nederland. De zorggroep ontving HL7v2 ADT-berichten (Admit, Discharge, Transfer) wanneer een van hun cliënten een ziekenhuisopname doorliep, zodat het portaal die wijziging kon laten zien aan de familie en de teamcoördinator. De oude integratie was een SFTP-drop met een pollende Drupal-module die berichten parste met regex.
We bouwden dit opnieuw als een kleine Deno-worker achter hetzelfde SFTP-endpoint, geschreven in 180 regels. Hij gebruikte een echte HL7v2-parser, valideerde tegen een bekende message-ID-range, en postte events naar Sanity via de mutation API.
Het interessante was niet de herschreven code. Het was de cutover. ChipSoft laat je de feed niet naar twee bestemmingen sturen. Je wijst hem op één plek aan. Dus voor de vier weken shadow-traffic lieten we de oude Drupal-poller draaien, en de nieuwe worker tailde dezelfde SFTP-drop met een ander lockfile-prefix. Beide verwerkten elk bericht. We vergeleken elke vijftien minuten de resulterende patiëntstatus-entiteiten in beide systemen. In week vijf hadden we vier mismatches op 11.200 berichten, allemaal op edge-case A08-updates waar de oude regex sinds 2019 stilletjes verkeerd had gestaan.
Shadow-traffic verkleint niet alleen het risico van het nieuwe systeem. Het leert je wat je oude systeem eigenlijk deed, inclusief de bugs die je niet meer opmerkte.
Cutover-zaterdag
De cutover zelf was bewust saai.
Op zaterdagochtend in week zes zetten we DNS over naar het nieuwe portaal. Beide stacks bleven live. De legacy PDF-URL's bleven werken. De HL7v2-worker was al de source of truth. De Drupal-site bleef zeven dagen in read-only mode draaien en werd daarna gearchiveerd als een statische HTML-mirror, waarbij alle dynamische endpoints werden vervangen door een 410 Gone.
We hadden die week drie incidenten. Eén opgeslagen login van een cliënt uit 2016 wees nog naar een oud SSO-endpoint dat we niet hadden meegemigreerd; we forwardden hem. Eén zorgmedewerker had een interne adminpagina gebookmarkt die geen deel uitmaakte van het nieuwe portaal; we voegden een redirect toe naar het dichtstbijzijnde equivalent. Eén PDF die was geüpload met een Unicode-bestandsnaam met een verdwaalde U+00A0 (non-breaking space) was door onze sha256-check gevallen en hadden we gemist in de audit; we hebben hem handmatig opnieuw ingelezen.
Dat was het. Geen dataverlies. Geen downtime op het portaal voor cliënten. De HL7v2-feed liep ongestoord door.
Wat we anders zouden doen
Twee dingen.
Eén: we onderschatten hoeveel kennis er in de e-mailtemplates zat. Drupals getokeniseerde e-mailbodies refereerden naar interne velden op machine-naam, en het hernoemen van die velden aan de Sanity-kant brak een paar getemplate e-mails die al een jaar niet meer waren geopend. We vingen ze op in de shadow-traffic, maar we hadden e-mailrendering vanaf week één in de diff-worker moeten zetten, niet vanaf week drie.
Twee: we hadden de export-tool voor de audit trail moeten bouwen voordat we de import-tool bouwden. We schreven de export pas nadat de eerste dry run had laten zien hoe makkelijk je de Wgbo-tijdstempels kunt verliezen. De juiste volgorde is: eerst de round-trip schrijven, dan pas de migratie.
De vorm van het werk
Toen we het nieuwe cliëntportaal voor deze zorggroep bouwden, was de les waar we steeds op terugkwamen dat een migratie geen herschrijving plus een deploy is. Het is een trage swap waarbij beide systemen tegelijk gelijk hebben, totdat je er één meer vertrouwt dan de ander. Heb je een Drupal 7-site die tegen een hosting-EOL aanloopt, of een Wgbo-audit waarvan je het verlies niet kunt opvangen: het werk dat wij doen rond legacy-migraties heeft dezelfde vorm: eerst shadowen, daarna pas cutoveren.
Het kleinste wat je vandaag kunt doen: schrijf de SQL-query die je file_managed-tabel exporteert, gejoind tegen het veld waar je business-artifacts daadwerkelijk in zitten. Als die join meer dan tien minuten kost om te schrijven, weet je waar de komende maand werk zit.
Kern
Shadow-traffic vier weken lang beide stacks, diff elke response, en cutover-dag verandert van deadline in een DNS-flip.
FAQ
Hoe lang duurt een migratie van Drupal 7 naar SvelteKit voor een portaal van deze omvang?
Zes gefocuste weken voor een portaal met ~50 custom modules, 30.000+ bestanden en één inkomende HL7-feed. Reken er twee weken bij op als het team nog geen shadow-traffic-plumbing heeft gedaan.
Waarom Drupal 7 niet gewoon upgraden naar Drupal 10?
Je herschrijft de custom modules sowieso, je betaalt alsnog de upgrade-tax, en je eindigt op een stack die het in-house team niet kan onderhouden. Voor zorgorganisaties onder de 50 mensen is een stap weg van Drupal over vijf jaar meestal goedkoper.
Wat gebeurt er met de oude Drupal-URL's na de cutover?
Houd een dun endpoint op de nieuwe stack dat legacy URL-patronen herkent en bestanden vanuit object storage streamt. Wij zien zes maanden later nog steeds rond de 1% van het PDF-verkeer op legacy URL's binnenkomen.
Hoe behoud je een Wgbo-audit trail over een migratie heen?
Modelleer hem als append-only event-documenten met expliciete originele tijdstempels, plus een ondertekende CSV-export van de oude logtabel in cold storage. Verifieer dat je import de originele tijdstempels respecteert voordat je hem op productie draait.
Kun je shadow-traffic op een HL7v2-feed zetten als de bron maar één bestemming toelaat?
Ja. Tail dezelfde SFTP-drop met een apart lockfile-prefix, verwerk elk bericht in beide systemen parallel, en diff de resulterende entiteiten elke vijftien minuten tot aan de cutover.