← Blog

Drupal

Drupal 7 to SvelteKit: a six-week zorgportaal cutover

A 23-person zorggroep in Alkmaar had a Drupal 7 portal, 32,800 zorgplan-PDFs, and an HL7 feed into ChipSoft HiX. Here is the six-week cutover, step by step.

Jacob Molkenboer· Founder · A Brand New Company· 19 Jun 2026· 9 min
Open leather logbook on ivory paper with brass key, linen-tied iron tag, chartreuse sticky note, red wax fragment.

The Tuesday the warning landed

It was a Tuesday in February. The operations lead of a 23-person zorggroep in Alkmaar opened her inbox and found a notice from her hosting partner: the shared PHP 7.2 server her cliëntenportaal had lived on since 2013 was being decommissioned in eight weeks. Drupal 7 had been off official support since January 2025. The portal was the daily home of 1,400 cliënten, their family members, and the 23-person team that coordinated their care. It was where zorgplannen got signed, where machtigingen got recorded under the Wgbo, and where new admissions silently flowed in from ChipSoft HiX over an HL7v2 ADT-feed that nobody on the current team had configured.

She called us on Wednesday morning.

This is the six-week story of cutting that portal over. The bones of the plan apply to any Drupal 7 site with a real database behind it: shadow-traffic both stacks, migrate by behaviour rather than by feature, and treat cutover day as a non-event.

Why we did not upgrade Drupal first

The instinct is to go Drupal 7 to Drupal 10 and call it a migration. We considered it for about an hour.

The portal had 47 custom modules. Twelve of them had a single author who had left in 2017. The PDF rendering went through a forked PHP library that did not work past PHP 7.4. The ChipSoft HiX feed was glued in with a custom module that polled an SFTP drop every two minutes and parsed HL7 segments with regex. Migrating that into a contemporary Drupal release would have meant rewriting the modules anyway, paying the Drupal upgrade tax on top, and ending up on a stack with no in-house operator at the zorggroep who could maintain it.

We picked Sanity for the content model and SvelteKit for the portal because the team had two part-time developers who already knew JavaScript and could read the codebase six months after we left. Drupal expertise in the Alkmaar area, at a budget a 23-person care group can pay, is a thinning market. SvelteKit knowledge is not.

The shadow-traffic six-week plan

Here is the actual schedule we ran.

Week 1  ────  discovery, read-only mirror, schema mapping
Week 2  ─┐
Week 3  ─┴──  new stack build, content model, auth, PDFs
Week 4  ────  shadow traffic, response diffing
Week 5  ────  weighted rollout: 5% → 20% → 50%
Week 6  ────  DNS cutover, 7-day hot standby

Week 4 was the load-bearing week. Every request that hit the old portal was replayed against the new portal, headers stripped, and the responses were diffed by a small worker that logged the differences. Week 5 was a slow weighted rollout. Week 6 was the cutover and the seven-day hot-standby of the old stack.

The reason this works for a portal of this size: you do not need to be clever. You need a diff tool and the patience to look at every mismatched response.

Moving 32,800 zorgplan-PDFs without breaking links

The PDFs were the part everyone worried about and the part that turned out to be tractable.

Each zorgplan-PDF was referenced from a node in Drupal, but the URL the cliënt actually clicked from their inbox was of the form /zorgplan/download/{nid}/{filehash}. That URL appeared in 17,000+ archived emails going back to 2014. Breaking it was not an option.

We did three things.

First, we exported the file map from Drupal's file_managed table joined against the field_data_field_zorgplan_pdf table. That gave us 32,800 rows of (nid, filehash, storage path, mime, size). We rsynced the files into an S3-compatible bucket and verified by sha256.

-- the joining query we ran against the old Drupal database
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;

Second, we built a single SvelteKit endpoint at the legacy URL pattern that read the (nid, filehash) pair, looked it up against a Postgres table populated from the export, checked the cliënt's session, and streamed the file from object storage. The endpoint logged every hit with the original Drupal nid so we could watch the long tail.

Third, in the new Sanity content model, every zorgplan document carried both its new ID and its legacy Drupal nid. The portal UI linked to the new URL. The old URL kept working for inbox archaeology.

Two weeks after cutover, 4% of PDF downloads were still hitting the legacy URL. Six months after cutover, that number is 1.1%. We will leave the endpoint up indefinitely.

Machtigings-history under the Wgbo

The Wgbo (Wet op de geneeskundige behandelingsovereenkomst) requires that consent for medical decisions be recorded, attributable, and recoverable on request by the cliënt or their representative. In the old portal, this history lived in two places: a node_revision row per consent change, and a custom machtigingen_log table that recorded who clicked the checkbox, from which IP, at what timestamp, and which version of the consent text they were shown.

You cannot drop revisions on a system like this. A consent given in 2019 to a treatment plan that read one way is not the same as a consent given in 2023 to a plan that reads another way, and a dispute three years from now needs both versions on file.

We modelled this in Sanity as an append-only machtigingen-event document type, with a reference back to the cliënt, a denormalised copy of the consent text as it was shown at the time, the actor (cliënt, vertegenwoordiger, or zorgmedewerker), the source controller in the portal, the IP and user-agent, and the ISO timestamp. The migration script walked the old machtigingen_log table and replayed every row into Sanity as a new event document with _createdAt set to the original timestamp.

Warning

Sanity defaults to setting _createdAt to ingestion time. If you do not set it explicitly on bulk import, your entire Wgbo audit trail collapses to the date of the migration. We caught this in the dry run. Test it before you run the import.

We also kept a verbatim CSV export of the old log table, signed and dated, in cold storage. If a court ever asks for the pre-migration trail, we hand them the original.

The HL7v2 ADT-feed into ChipSoft HiX

This was the part that kept us up at night.

ChipSoft HiX is the dominant hospital information system in the Netherlands. The zorggroep received HL7v2 ADT messages (Admit, Discharge, Transfer) when one of their cliënten moved through a hospital admission, so that the portal could surface the change to the family and the team coordinator. The old integration was an SFTP drop with a polling Drupal module that parsed messages with regex.

We rebuilt this as a small Deno worker, sitting behind the same SFTP endpoint, written in 180 lines. It used a real HL7v2 parser, validated against a known message ID range, and posted events into Sanity over the mutation API.

The interesting part was not the rewrite. It was the cutover. ChipSoft does not let you fan out the feed to two destinations. You point it at one place. So for the four weeks of shadow traffic, we kept the old Drupal poller running, and the new worker tailed the same SFTP drop with a different lockfile prefix. Both processed every message. We diffed the resulting patient-status entities in both systems every fifteen minutes. By week five we had four mismatches on 11,200 messages, all of them on edge-case A08 updates where the old regex had quietly been wrong since 2019.

Shadow traffic does not just de-risk the new system. It teaches you what your old system was actually doing, including the bugs you had stopped noticing.

Cutover Saturday

The cutover itself was deliberately boring.

On the Saturday morning of week six, we flipped DNS to the new portal. Both stacks stayed live. The legacy PDF URLs continued to resolve. The HL7v2 worker was already the source of truth. The Drupal site stayed up in read-only mode for seven days, then was archived to a static HTML mirror with all dynamic endpoints replaced by a 410 Gone.

We had three incidents that week. One cliënt's saved login from 2016 still pointed to an old SSO endpoint we had not migrated; we forwarded it. One zorgmedewerker had bookmarked an internal admin page that was not part of the new portal; we added a redirect to the closest equivalent. One PDF that had been uploaded with a Unicode filename containing a stray U+00A0 (non-breaking space) had failed our sha256 check and we missed it on the audit; we re-ingested it manually.

That was it. No data loss. No downtime for the cliënt-facing portal. The HL7v2 feed went uninterrupted.

What we would do differently

Two things.

First, we underestimated how much knowledge sat in the email templates. Drupal's tokenised email bodies referenced internal fields by machine name, and renaming those fields on the Sanity side broke a few templated emails that hadn't been opened in a year. We caught them on shadow traffic, but we should have included email rendering in the diff worker from week one, not week three.

Second, we should have built the audit-trail export tool before we built the import tool. We wrote the export only after the first dry-run revealed how easy it would be to lose the Wgbo timestamps. The right order is: write the round-trip first, then the migration.

The shape of the work

When we built the new patient portal for this zorggroep, the lesson we kept coming back to was that a migration is not a rewrite plus a deploy. It is a slow swap where both systems are right at the same time, until you trust one of them more. If you have a Drupal 7 site staring down a hosting EOL or a Wgbo audit you cannot afford to lose, the work we do on legacy migrations is the same shape: shadow first, cut over last.

The smallest thing you could do today: write the SQL query that exports your file_managed table joined against the field that actually holds your business artefacts. If that join takes more than ten minutes to write, you have just learned where the next month of work lives.

Key takeaway

Shadow-traffic both stacks for four weeks, diff every response, and cutover day stops being a deadline and becomes a DNS flip.

FAQ

How long does a Drupal 7 to SvelteKit migration take for a portal this size?

Six focused weeks for a portal with ~50 custom modules, 30k+ files, and one inbound HL7 feed. Add two weeks if the team has not done shadow-traffic plumbing before.

Why not just upgrade Drupal 7 to Drupal 10?

You still rewrite the custom modules, you still pay the upgrade tax, and you end up on a stack the in-house team cannot maintain. For care orgs under 50 people, off-Drupal is usually cheaper over five years.

What happens to the old Drupal URLs after cutover?

Keep a thin endpoint on the new stack that resolves legacy URL patterns and streams from object storage. We still see around 1% of PDF traffic on legacy URLs six months in.

How do you preserve a Wgbo audit trail across a migration?

Model it as append-only event documents with explicit original timestamps, plus a signed CSV export of the old log table in cold storage. Verify your import respects original timestamps before running it on production.

Can you shadow-traffic an HL7v2 feed when the source only allows one destination?

Yes. Tail the same SFTP drop with a separate lockfile prefix, process every message in both systems in parallel, and diff the resulting entities on a fifteen-minute cadence until cutover.

drupalmigrationlegacy sitescase studyphparchitecture

Building something?

Start a project