← Blog

Migration

Van Drupal 7 naar Payload: shadow-cutover in vier weken

Op een dinsdag in maart logde het Drupal 7-ledenportaal van een Leidse vereniging zijn laatste SEPA-batch. Drie weken later draaide het op Payload CMS, Hono en Postgres. Zo deden we dat.

Jacob Molkenboer· Oprichter · A Brand New Company· 15 jun 2026· 10 min
Open leren logboek, koperen sleutel op crème kaart, inktkussen, groen lint, rood waxfragment op ivoor papier.

Eind februari stuurde een bestuurslid van een Leidse beroepsvereniging van 22 personen ons een screenshot van hun ledenportaal. De Drupal-adminbalk bovenin was oranje. In de footer stond nog steeds Powered by Drupal 7. Het CMS waarop de site draaide was end-of-life sinds 5 januari 2025, en de PHP eronder kreeg al sinds 2020 geen support meer. De site lag er niet uit. Maar hij was ook al vijftien maanden niet gepatcht. Hun accountant had net in de jaarlijkse audit de SEPA-pipeline geflagd. Zesduizend tweehonderd actieve machtigingen stonden in een custom sepa_mandates-tabel in MySQL, en de accountant wilde weten wie aansprakelijk was als er een rij verdween.

Zo zijn we te werk gegaan om hem te verhuizen. Vier weken, shadow traffic, geen downtime langer dan een DNS-TTL.

De vorm van zeventien jaar data

Week één ging niet over code. Week één ging over een spreadsheet.

Een Drupal 7-site die sinds 2009 draait (oorspronkelijk een D6-install, in 2014 gemigreerd naar D7, daarna laten verouderen) is nooit zomaar een CMS. Toen we phpMyAdmin openden had het schema 312 tabellen. Ongeveer 40 daarvan waren Drupal core, 80 hoorden bij contributed modules die het team al lang niet meer gebruikte, en de rest was custom: members_extra, sepa_mandates, sepa_batches, committee_membership, event_registration_v2 (die v2 doet echt werk). Het portaal schreef ook in GroupOffice via een zelfgebouwde webhook-bus: een PHP-cron schraapte elke vijf minuten de watchdog-tabel op trefwoorden en POSTte die naar een GroupOffice-endpoint. Niemand van het huidige bestuur had die bus gebouwd, en de oorspronkelijke developer was naar Australië verhuisd.

We begonnen met elke tabel te lezen. Niet queryen, lezen. De vorm van de data vertelt je wat de applicatie écht doet, en dat is meestal 30% van wat de docs beweren. Als een legacy MySQL een _v2, _old of _backup-tabel heeft, grep dan eerst de codebase op die naam voordat je aanneemt dat hij dood is. We hebben twee keer productie-reads gevonden op tabellen die officieel allang gearchiveerd waren.

De stack die we kozen, en waarom

De briefing van het bestuur was kort: hou het saai, hou het Nederlands-hostbaar, en hou ons uit vendor lock-in. De nieuwe stack werd Payload CMS op Node 22 voor de admin en het contentmodel, Hono voor de SEPA- en webhook-endpoints, en Postgres 16 voor de opslag.

Het collection-model van Payload is een JavaScript-object dat je in git zet, en dat lost meteen het pijnlijkste deel van Drupal 7-migraties op: content types die in de database leven. Hono draait op gewoon Node, op Bun, of op een worker. Zo lieten we een deur openstaan, ook al deployen we voorlopig naar Node. Postgres verving MySQL omdat de SEPA- en audit-logica echte transacties en fatsoenlijke enums nodig had. De MySQL 5.7 op de oude bak handhaafde niet eens CHECK-constraints. We kozen geen Strapi (admin te star voor de commissielogica), geen Directus (het team had geen zin in nog een admin-SPA), en geen Laravel (een redelijke optie, maar de twee in-house developers waren al een Node-shop).

Het shadow-trafficplan

Het risico bij elke migratie die geld raakt, is dat je de bug pas vindt nadat de klanten hem al gevonden hebben. We leenden een patroon uit de API-wereld: shadow traffic. Twee weken voor de cutover werd elke request die binnenkwam bij de oude Drupal-site ook asynchroon doorgezet naar de nieuwe Payload- en Hono-stack. Beide schreven naar hun eigen database. Beide produceerden hun eigen SEPA-exports. We diffden de exports elke nacht en zetten elk verschil door naar een Slack-kanaal genaamd #sepa-diff.

De forwarder was een klein Caddy-blok voor de bestaande nginx, met een mirror-worker die de duplicatie deed:

leden.example.nl {
    reverse_proxy old-portal:80
    # Mirror worker reads the access log tail and replays each
    # request body against new-portal:3000, fire-and-forget.
}

leden-shadow.internal {
    reverse_proxy new-portal:3000
}

De mirror zelf was een Hono-worker die de request body kopieerde, hem opnieuw afspeelde tegen de nieuwe stack, en de response code, latency en body hash logde in een Postgres-tabel. Twee weken mirror traffic leverden ons ruwweg 14.000 gepaarde requests op om te diffen. Op dag één waren er ongeveer 80 die afweken. Op dag veertien was de teller nul.

Week één: schema en read-side-pariteit

Week één ging over de data in Postgres krijgen en de read-kant van het portaal aan de praat. We gebruikten pgloader voor de bulkverhuizing van MySQL naar Postgres, en zetten daar een handgeschreven transform overheen:

pgloader \
  --with "quote identifiers" \
  --with "data only" \
  --with "preserve index names" \
  mysql://reader:***@old-db/ledenportaal \
  postgresql://migrator:***@new-db/ledenportaal_raw

Het rauwe schema landde in een legacy Postgres-schema. Daar werd daarna niets meer aangeraakt. De nieuwe, Payload-vormige tabellen stonden in public, en een set versienummers SQL-scripts (001_members.sql, 002_mandates.sql, enzovoort) las uit legacy en schreef naar public. We konden de hele pipeline in 40 seconden opnieuw draaien. Dat betekende dat we het ons vijftig keer fout konden permitteren.

De Payload-collection voor members zag er uiteindelijk zo uit:

import type { CollectionConfig } from 'payload'

export const Members: CollectionConfig = {
  slug: 'members',
  admin: { useAsTitle: 'fullName' },
  fields: [
    { name: 'legacyId', type: 'number', unique: true, index: true },
    { name: 'fullName', type: 'text', required: true },
    { name: 'email', type: 'email', required: true, unique: true },
    { name: 'memberSince', type: 'date' },
    { name: 'committees', type: 'relationship', relationTo: 'committees', hasMany: true },
    { name: 'mandateRef', type: 'text', index: true }, // SEPA UMR
  ],
}

Het legacyId-veld is de goedkope verzekering van elke migratie die we ooit hebben gedaan. Houd de oude primary key, geïndexeerd en uniek, op elke rij. Als er zes maanden later iets misgaat en een bestuurslid vraagt waarom zijn commissievoorzitter-badge is verdwenen, kun je in één query terug-joinen naar het legacy-schema.

Week twee: de SEPA-poort

Van de 6.200 machtigingen sliepen we het slechtst. Een SEPA-incassomachtiging is een juridisch document. Hij heeft een UMR (Unique Mandate Reference), een handtekeningdatum, een IBAN en een status. Raak je de UMR kwijt of verander je de handtekeningdatum, dan weigert de bank de volgende incasso en krijgt de vereniging geen geld.

Twee dingen hebben ons gered. Eén: we lieten het nieuwe systeem geen enkele machtiging schrijven tijdens het migratievenster. Machtigingen stroomden één kant op: oud portaal naar nieuwe database. Twee: we bouwden een verifier die elke nacht de SEPA pain.008-export van beide stacks vergeleek en cutover weigerde als ook maar één rij afweek.

// hono-app/src/sepa/verify.ts
import { Hono } from 'hono'
import { compareExports } from './diff'

const app = new Hono()

app.get('/sepa/verify-tonight', async (c) => {
  const legacy = await fetchLegacyExport()   // .xml from the old PHP cron
  const fresh  = await buildFreshExport()    // generated from Postgres

  const diff = compareExports(legacy, fresh)
  if (diff.length === 0) {
    return c.json({ ok: true, mandates: legacy.count })
  }
  await postSlack('#sepa-diff', diff)
  return c.json({ ok: false, diff }, 409)
})

export default app

Op dag drie van week twee ving de diff een echte bug op. De legacy PHP-code stripte voorloopnullen uit het BIC-veld bij export. Onze nieuwe export bewaarde ze. De bank had jaren BIC's zonder voorloopnullen ontvangen, ze toch geaccepteerd, en de legacy was technisch fout. We kopieerden het legacy-gedrag voor het migratievenster en zetten een ticket open om het de week daarna op te ruimen.

Les

Het juiste gedrag voor een cutover is niet het correcte gedrag. Het is wat de bank tien jaar lang stilletjes heeft geaccepteerd. Fix de bug nadat de diff groen is, niet ervoor.

Week drie: de GroupOffice webhook-bus

De PHP-cron die watchdog schraapte en naar GroupOffice POSTte, was het soort code waarvan je de auteur wilt bellen. Tien bestanden, geen tests, overal magische getallen. We hebben hem niet herschreven. We hebben hem ingepakt.

De nieuwe Hono-app stelde precies dezelfde endpoint-vorm beschikbaar waar de oude cron naar POSTte. In week drie draaide de cron nog steeds, maar hij stuurde zijn payloads naar een Hono-route die twee dingen deed: de call onveranderd doorzetten naar GroupOffice, en de payload opslaan in Postgres. Na vijf dagen hadden we een compleet replay-logboek van elk CRM-event dat het portaal ooit had afgevuurd. Daarna schreven we een nieuwe TypeScript event emitter die dezelfde payloads produceerde vanuit Payloads afterChange-hooks, en speelden we drie dagen aan live events erdoorheen tegen een staging-GroupOffice. Toen de diffs matchten, zetten we de PHP-cron uit.

// Payload collection hook
hooks: {
  afterChange: [
    async ({ doc, operation, req }) => {
      if (operation !== 'create' && operation !== 'update') return
      await req.payload.create({
        collection: 'crm-events',
        data: {
          type: `member.${operation}`,
          payload: doc,
          dispatchedAt: null,
        },
      })
    },
  ],
}

Een aparte Hono-worker leegde de crm-events-collection elke 30 seconden en POSTte naar GroupOffice, met retry, dead-letter en dezelfde magische getallen die de oude PHP gebruikte. Saai is het doel.

Week vier: het cutover-uur

Cutover was zondag om 06:00. De DNS-TTL op leden.example.nl was de woensdag ervoor naar 60 seconden gezet. Het oude portaal ging om 05:55 in read-only modus. Om 06:00 zetten we Caddy om naar 100% verkeer naar de nieuwe stack in plaats van mirroren. De mirror bleef nog 48 uur draaien, maar nu in omgekeerde richting: elke write op het nieuwe portaal werd ook tegen het oude afgespeeld, voor het geval we terug moesten.

We hoefden niet terug. Twee leden meldden dat hun wachtwoord niet werkte, wat we hadden verwacht: het oude portaal sloeg wachtwoorden op in een half-bcrypt-half-MD5-hybride die niemand kon uitleggen. We hadden elk lid vooraf per mail gewaarschuwd en op de loginpagina een reset-wachtwoord-melding gezet. Beide leden resetten binnen een minuut en we hoorden nooit meer iets van ze.

Drie dingen die we de avond ervoor deden en die we voor altijd blijven doen:

  1. Druk het runbook af op papier. De wifi op het verenigingskantoor is uit 2011 en kiest zijn eigen momenten.
  2. Zet de oude database read-only op MySQL-user-niveau, niet op applicatieniveau. Read-only op applicatieniveau is een instelling. Read-only op databaseniveau is een feit.
  3. Houd de oude bak nog 90 dagen na cutover draaien. De kosten van een virtuele machine in leven houden zijn twaalf euro per maand. De kosten van hem nodig hebben en niet hebben zijn een bestuursvergadering.

Twee dingen waar we achteraf spijt van hadden

We hebben in het begin vier dagen verspild aan het reverse-engineeren van het legacy SEPA-exportformaat uit de PHP. We hadden vanaf dag één gewoon twee echte exports byte voor byte moeten diffen. In week twee deden we dat alsnog, en bij de volgende migratie van dit type scheelde dat ons een week. En we hebben onderschat hoeveel bestuurs-energie een portaalmigratie opslokt. Tweeëntwintig mensen is klein genoeg dat elk lid de mensen in het bestuur kent, en dus wordt elke loginbug een persoonlijk telefoontje. Het cutover-schema communiceren bleek een derde van het werk.

Het kleinste wat je vandaag kunt doen, als je een legacy Drupal 7-site draait: draai SHOW TABLES op je database en grep je codebase op elke tabelnaam. Alles wat nul hits oplevert, is óf dood óf doet iets waar niemand in je team weet van heeft. Beide zijn het waard om te weten.

Toen we het nieuwe ledenportaal bouwden, was de SEPA-pipeline het deel waar we het meest van schrokken. We losten het op met een one-way mirror en een nachtelijke diff tegen de legacy-export, en we gebruiken hetzelfde shadow-traffic-patroon bij elke legacy migratie die we aannemen. Het patroon is ouder dan wij. Het werkt gewoon.

Kern

Shadow traffic plus een nachtelijke export-diff maakt een enge SEPA-migratie saai. Eerst de legacy-bug nabootsen, daarna pas fixen.

FAQ

Kun je SEPA-machtigingen migreren zonder de volgende incasso te breken?

Ja, als de machtigingen tijdens het migratievenster één kant op stromen en je de nachtelijke pain.008-export tussen oud en nieuw diff totdat de diff leeg is. Doe cutover pas als hij twee nachten leeg blijft.

Waarom Payload CMS kiezen boven Drupal 10 of WordPress voor een ledenportaal?

Payload houdt het contentmodel in TypeScript-bestanden in git. Drupal en WordPress houden het in de database, en juist daardoor zijn hun langetermijnmigraties zo pijnlijk.

Hoe lang zou een Drupal 7-portaalmigratie eigenlijk moeten duren?

Voor een portaal van rond de 6.000 records met custom SEPA-logica en een CRM webhook-bus is vier weken realistisch als je shadow traffic draait. Onder de twee weken is wishful thinking; boven de acht weken is scope creep.

Wat is shadow traffic en waarom gebruik je het bij een migratie?

Shadow traffic mirrort elke live request naar de nieuwe stack zonder dat de gebruiker er iets van merkt. Je vergelijkt de outputs elke nacht. Zo vind je bugs onder productiecondities voordat een gebruiker ze tegenkomt.

migrationdrupalphpmysqllegacy sitesarchitecture

Iets bouwen?

Start een project