← Blog

WordPress

WordPress 5.2 naar Payload: shadow-traffic in vier weken

Een 11 jaar oud WordPress 5.2-portaal, 18.700 CBR-dossiers, 14 instructeurs in het RDW-register en vier weken tijd. Dit is het shadow-traffic-draaiboek dat we volgden.

Jacob Molkenboer· Oprichter · A Brand New Company· 11 mei 2026· 9 min
Gesloten leren logboek met linnen koord, koperen sleutel op crème kaartje, groen lint, op ivoorkleurig papier.

Het telefoontje kwam in februari binnen. De eigenaar van een Hoornse rijschoolgroep — drie vestigingen, 21 medewerkers, 14 instructeurs — had een brief van zijn verzekeraar op tafel liggen. Het leerlingenportaal op zijn domein draaide WordPress 5.2 op PHP 7.4. Het audit-team van de verzekeraar had beide aangemerkt. Verlenging hing af van een herstelplan vóór april.

WordPress 5.2 kreeg in 2023 zijn laatste security-backport. PHP 7.4 bereikte op 28 november 2022 end-of-life. Het portaal zelf was elf jaar oud. Het was gegroeid rond Gravity Forms, zes custom PHP-plugins geschreven door drie verschillende freelancers, en een wp_postmeta-tabel met 4,1 miljoen rijen.

Er stonden ook 18.700 actieve CBR-tussentijdsetoetsdossiers in, klokuurhistorie per leerling die vijf jaar lang opvraagbaar moest blijven onder de WRM, en een RDW WBR-koppeling waar de 14 instructeurs elke ochtend op inlogden. We hadden vier weken.

Dit is het draaiboek.

De inventarisatie

Voordat we iets aanraakten, deden we een platte audit. Drie vragen: wat is de echte data, wat raakt de buitenwereld, en wie logt er in.

De data bestond uit drie custom post types (dossier, klokuur, instructeur), 41 Gravity Forms entry-tabellen, een export van CBR-tussentijdsetoets-PDF's in wp-content/uploads/cbr/, en een custom audit-log-tabel die sinds 2017 niet was opgeschoond.

Het oppervlak naar de buitenwereld was klein: een RDW-koppeling die twee keer per dag rdw.nl aanriep over SOAP, een CBR-export uit een cron-curl-script, en een SMTP-relay voor oudermeldingen.

Het login-oppervlak was één enkele wp-login.php met een custom MFA-plugin die sinds 2021 niet was aangeraakt.

Totaal: 14 instructeurs, ongeveer 600 actieve leerlingen, drie back-office-admins.

# Wat we op dag één draaiden, tegen een read-only replica
wp post list --post_type=dossier --format=count
wp post list --post_type=klokuur --format=count
wp eval 'echo count(get_posts(["post_type"=>"any","posts_per_page"=>-1]));'
mysqldump --single-transaction --no-data legacy_db \
  | grep CREATE | wc -l

Het dossieraantal kwam uit op 18.743. De klokuren stonden op 412.609. Zevenenveertig unieke CREATE-statements, waarvan veertien nergens in de actieve codebase werden gebruikt.

Waarom Payload, geen headless WordPress

De saaie keuze was geweest: WordPress naar 6.5 upgraden, PHP omzetten naar 8.2, een Next.js-frontend tegen de REST API plakken. We hebben het overwogen. We hebben het op drie gronden afgewezen.

Ten eerste, de zes custom plugins. Twee daarvan gebruikten onder de motorkap verouderde mysql_*-calls. Die naar PHP 8.2 brengen was een project op zich. We waren twee van onze vier weken kwijt geweest aan plugin-archeologie.

Ten tweede, het schema. Het dossier was een custom post type. Elk veld stond in wp_postmeta. Een query als "geef me alle dossiers waar de CBR-toets deze week gepland staat en de instructeur op vakantie is" vroeg om een JOIN over vijf tabellen, die 2,3 seconden draaide tegen een warme cache. Met 14 instructeurs en drie admins die elke minuut ververst, was wp_postmeta de hele bottleneck.

Ten derde, de auth. WordPress-sessions in een portaal dat een WRM-audit moet doorstaan zijn meer een documentatieprobleem dan een security-probleem. Payload geeft ons getypeerde collections, row-level access-functies, en een standaard admin-UI die we niet hoefden te restylen.

We kozen Payload 3 op Next.js 15, met MongoDB voor de document-kant (dossiers, klokuren, PDF's) en Postgres voor de relationele kant (leerlingen, instructeurs, audit log). Twee databases is een belasting. Voor deze vorm van data was het de moeite waard.

De collection die de WRM-klok draait

Payload-collections zijn TypeScript. De vertaling van custom post type plus ACF/meta naar een getypeerde collection is het meest waardevolle artefact van het project, omdat het het veld-voor-veld-gesprek met de klant forceert voordat er één regel code draait.

// src/collections/Dossier.ts
import type { CollectionConfig } from 'payload'
import { addYears } from 'date-fns'

export const Dossier: CollectionConfig = {
  slug: 'dossiers',
  access: {
    read: ({ req: { user } }) =>
      user?.role === 'admin'
        ? true
        : { instructeur: { equals: user?.id } },
  },
  fields: [
    { name: 'leerling', type: 'relationship', relationTo: 'leerlingen', required: true },
    { name: 'instructeur', type: 'relationship', relationTo: 'instructeurs', required: true },
    { name: 'cbrReferentie', type: 'text', unique: true, index: true },
    { name: 'tussentijdseToets', type: 'group', fields: [
      { name: 'gepland', type: 'date' },
      { name: 'uitslag', type: 'select',
        options: ['voldoende', 'onvoldoende', 'geannuleerd'] },
      { name: 'pdf', type: 'upload', relationTo: 'media' },
    ]},
    { name: 'klokuren', type: 'join', collection: 'klokuren', on: 'dossier' },
    { name: 'bewaarTot', type: 'date', admin: { readOnly: true } },
    { name: 'legacyId', type: 'number', index: true, admin: { readOnly: true } },
  ],
  hooks: {
    beforeChange: [({ data }) => ({
      ...data,
      bewaarTot: data.bewaarTot ?? addYears(new Date(), 5),
    })],
  },
}

Dat bewaarTot-veld is de WRM-klok. Elk dossier draagt vanaf het moment van aanmaken een retentiestempel van vijf jaar. Een nachtelijke job zet alles dat bewaarTot voorbij is over in een cold-storage-collection. Dat is het verschil tussen "we voldoen" en "we kunnen de inspecteur de rij laten zien die bewijst dat we voldoen".

Week 1 — schema en seed

We zetten Payload op achter nieuw.rijschool.local, richtten het op een verse MongoDB-cluster, en draaiden de seed.

De seed is één Node-script dat leest uit een read-only MySQL-replica en schrijft naar de local API van Payload. Geen HTTP. Geen REST. Met de local API roep je payload.create({ collection, data }) direct binnen het Node-proces aan, zonder netwerkkosten, zonder auth, zonder dubbele validatie.

import { getPayload } from 'payload'
import config from '../payload.config'
import mysql from 'mysql2/promise'
import { addYears } from 'date-fns'

const payload = await getPayload({ config })
const legacy = await mysql.createConnection(process.env.LEGACY_DSN!)

const [rows] = await legacy.execute(`
  SELECT p.ID, p.post_date,
         MAX(CASE WHEN m.meta_key='cbr_ref'        THEN m.meta_value END) AS cbr_ref,
         MAX(CASE WHEN m.meta_key='leerling_id'    THEN m.meta_value END) AS leerling_id,
         MAX(CASE WHEN m.meta_key='instructeur_id' THEN m.meta_value END) AS instr_id
  FROM wp_posts p
  JOIN wp_postmeta m ON m.post_id = p.ID
  WHERE p.post_type = 'dossier' AND p.post_status = 'publish'
  GROUP BY p.ID
`)

for (const r of rows as any[]) {
  await payload.create({
    collection: 'dossiers',
    data: {
      legacyId: r.ID,
      cbrReferentie: r.cbr_ref,
      leerling: idMap.leerlingen[r.leerling_id],
      instructeur: idMap.instructeurs[r.instr_id],
      bewaarTot: addYears(new Date(r.post_date), 5),
    },
  })
}

De eerste run produceerde 18.612 rijen in 41 minuten. De 131 ontbrekende dossiers waren leerlingen die in 2019 door een back-office-admin waren verwijderd, maar wiens dossiers als wezen waren blijven hangen. We zetten ze in een CSV en lieten de eigenaar beslissen. Hij hield ze, opnieuw gekoppeld aan een synthetische uitgeschreven leerling, zodat de audit log doorlopend bleef.

Week 2 — shadow traffic

Shadow traffic is het onderdeel dat de meeste teams overslaan en waar de meeste teams spijt van krijgen. We zetten een kleine reverse-proxy voor WordPress die elke geauthenticeerde POST en PUT ook naar Payload spiegelde. Reads gingen nog steeds naar WordPress. Writes raakten allebei.

De mirror was zestig regels Node achter nginx. Voor elke form-submission naar /wp-admin/admin-ajax.php die paste op een shortlist van actions (klokuur-toevoegen, dossier-update, tussentijdse-toets-uitslag) vertaalde hij naar de Payload local API, schreef een parallel record, en zette een diff in de wachtrij voor het ochtendrapport.

async function mirror(req: Request) {
  const wpRes = await fetch(`http://legacy.local${req.url}`, forward(req))
  // Fire and forget — blokkeer WordPress nooit op Payload
  void writeShadow(req).catch(e => log.warn({ e }, 'shadow miss'))
  return wpRes
}

Tegen vrijdag van week twee hadden we 4.118 gespiegelde writes en 87 mismatches. Eénenzeventig daarvan waren timezone-offset-bugs in de legacy-code die niemand ooit had opgemerkt, omdat de WordPress-weergave consequent de verkeerde tijd toonde. We lieten Payload de getoonde tijd matchen, niet de opgeslagen tijd. De eigenaar tekende de beslissing mee in een Loom-opname. Hij wilde continuïteit, geen correctheid, op dossiers die al in omloop waren.

Let op

Als shadow traffic een bestaande bug aan het licht brengt, beslis dan expliciet: behoud je hem voor continuïteit, of fix je hem en breek je het audit trail? Schrijf de beslissing op. Laat de klant meetekenen. "We dachten dat" is geen verdediging tegenover een inspecteur.

Week 3 — RDW, de instructeur-app, pariteit

De RDW-koppeling was het enige stuk dat we echt niet konden breken. Het is het register waartegen de pas van elke instructeur wordt gecheckt. De legacy-code was een PHP-class van 600 regels die SOAP postte en responses parste met simplexml. We schreven het over naar een TypeScript-module van 90 regels achter een Payload-endpoint, hielden de request envelope byte-voor-byte gelijk, en speelden de calls van de week ervoor opnieuw af tegen beide implementaties. 100% pariteit voor 14 instructeurs over vijf werkdagen.

De instructeur-app was een Next.js route group. We bouwden geen aparte React Native-shell. De instructeurs gebruiken hem op iPads in de auto's, op plekken met wisselend 4G. Een PWA met offline klokuur-queueing en een service-worker-sync dekte het gebruik, en we hoefden niet binnen vier weken naar de App Store te shippen.

Week 4 — cutover op vrijdag

We schakelden over op een vrijdagavond, 19:00 CET. Drie stappen.

  1. Bevries legacy writes. De mirror schakelde van "WordPress primair, Payload shadow" naar "Payload primair, WordPress shadow". We lieten WordPress nog twee weken live en read-only staan als vangnet.
  2. Draai de finale delta-seed. Alles wat tussen de laatste volledige seed en de freeze geschreven was, ging mee: 412 dossiers, 9.841 klokuren, twee nieuwe instructeurs die die week waren aangenomen.
  3. Flip DNS op de CDN. De nieuwe TTL stond op 60 seconden. Het oude portaal kreeg op de load balancer een 302 naar de nieuwe URL.

De eerste login van een instructeur, maandagochtend om 06:42, werkte. De tweede — op een 4G-iPad in een Fiat 500 bij station Hoorn — werkte ook. Die dag geen supporttickets. Twee op dinsdag, allebei over een lettertype dat één pixel kleiner was.

Het artefact dat je achterlaat

De oplevering van een migratie als deze is niet het nieuwe portaal. Het is de administratie. Elk dossier draagt een legacyId, elke klokuur een migratedAt, de WRM-retentieklok is een veld in plaats van een mapconventie. Een inspecteur kan zichzelf in 30 seconden naar een antwoord SQL-en.

Dat, meer dan de keuze van het framework, hield de verzekeringsverlenging op de rails. WordPress 5.2 was de aanleiding. Het datamodel was het project.

Toen we dit portaal bouwden voor de rijschoolgroep in Hoorn was het script dat we steeds opnieuw draaiden de shadow-traffic-mirror — acht keer in week twee, elke ronde de pariteitscheck strakker aanhalend tot het diff-rapport leeg terugkwam. Zit jij naar een vergelijkbare end-of-life-brief te kijken voor een custom WordPress-build, dan begint ons werk aan legacy-migraties meestal precies hier: een inventarisatie, een getypeerd schema, en een klok van vier weken.

Het kleinste dat je vandaag kunt doen: open phpMyAdmin, draai SELECT COUNT(*) FROM wp_postmeta; tegen je productiedatabase, en schrijf het getal op een geeltje. Is het meer dan een miljoen en heb je meer dan twee integraties die externe toezichthouders raken, dan heb je een shadow-traffic-project, geen upgrade.

Kern

Een gereguleerd WordPress-portaal migreren is vooral schema-werk: kies een stack waarin je de data kunt modelleren, spiegel je writes twee weken lang, en schakel op een vrijdag over.

FAQ

Waarom niet gewoon WordPress upgraden naar 6.5 en PHP naar 8.2?

Twee van de zes custom plugins gebruikten verouderde mysql_*-calls. Die vooruit porten had twee van onze vier weken opgegeten, en het wp_postmeta JOIN-probleem had de upgrade gewoon overleefd.

Wat betekent shadow traffic hier precies?

Een reverse proxy houdt WordPress als bron van waarheid voor reads, maar spiegelt elke geauthenticeerde write parallel naar Payload. Je krijgt twee weken een pariteitstest op echte traffic voordat je primair omschakelt.

Hoe voldeden jullie aan de 5-jaars WRM-bewaartermijn?

Elk dossier draagt een bewaarTot-datumveld dat bij aanmaak wordt gezet. Een nachtelijke job zet alles na die datum over in cold storage. De klok is een kolom, geen mapregel, dus een inspecteur kan er direct op querien.

Kun je dit ook zonder Payload — bijvoorbeeld met Strapi of Directus?

Ja. Het patroon is: de local API van het framework plus getypeerde collections plus een row-level access-functie. We kozen Payload voor de TypeScript-ergonomie, maar Directus en Strapi kunnen hetzelfde draaiboek draaien.

Wat was het meest risicovolle stuk van de vier weken?

De RDW-koppeling. Het was de enige integratie die we geen uur konden breken. Een werkweek lang byte-voor-byte replay van de SOAP-envelope tegen beide implementaties was de enige manier om er zeker van te zijn.

wordpressmigrationlegacy sitesphpcase studyarchitecture

Iets bouwen?

Start een project