← Blog

WordPress

WordPress 5.2 to Payload: a four-week shadow-traffic cutover

An 11-year-old WordPress 5.2 portal, 18,700 CBR dossiers, 14 instructors on the RDW register, and four weeks. Here is the shadow-traffic playbook we ran.

Jacob Molkenboer· Founder · A Brand New Company· 21 Jun 2026· 9 min
Closed leather logbook tied with linen cord, brass key on cream card, green ribbon marker, on ivory paper.

The phone call came in February. The owner of a Hoorn rijschool group — three locations, 21 staff, 14 instructors — had a letter from his insurer on his desk. The student portal at his domain was running WordPress 5.2 on PHP 7.4. The insurer's audit team had flagged both. Renewal was conditional on a remediation plan by April.

WordPress 5.2 last received a security backport in 2023. PHP 7.4 reached end of life on 28 November 2022. The portal itself was eleven years old. It had grown around Gravity Forms, six custom PHP plugins written by three different freelancers, and a wp_postmeta table with 4.1 million rows.

It also held 18,700 active CBR tussentijdse-toets dossiers, per-leerling klokuur-history that had to be retrievable for five years under the WRM, and an RDW WBR koppeling that the 14 instructors logged into every morning. We had four weeks.

This is the playbook.

The inventory

Before touching anything, we ran a flat audit. Three questions: what is the actual data, what touches the outside world, and who logs in.

The data was three custom post types (dossier, klokuur, instructeur), 41 Gravity Forms entry tables, an export of CBR tussentijdse-toets PDFs sitting in wp-content/uploads/cbr/, and a custom audit-log table that had not been pruned since 2017.

The outside-world surface was small: an RDW koppeling calling rdw.nl over SOAP twice a day, a CBR export running from a cron-curl script, and an SMTP relay for parent notifications.

The login surface was a single wp-login.php with a custom MFA plugin that had not been touched since 2021.

Total: 14 instructors, about 600 active leerlingen, three back-office admins.

# What we ran on day one, against a 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

The dossier count came back as 18,743. The klokuur count was 412,609. Forty-seven distinct CREATE statements, of which fourteen were not referenced anywhere in the active codebase.

Why Payload, not headless WordPress

The boring choice would have been: upgrade WordPress to 6.5, swap PHP to 8.2, bolt a Next.js front end onto the REST API. We considered it. We rejected it on three grounds.

First, the six custom plugins. Two of them used deprecated mysql_* calls under the hood. Bringing them forward to PHP 8.2 was its own project. We would have spent two of our four weeks on plugin archaeology.

Second, the schema. The dossier was a custom post type. Every field lived in wp_postmeta. Querying "give me all dossiers where the CBR-toets is scheduled this week and the instructeur is on vakantie" required a five-way JOIN that ran in 2.3 seconds against a warm cache. With 14 instructors and three admins refreshing every minute, wp_postmeta was the whole bottleneck.

Third, the auth. WordPress sessions inside a portal that has to satisfy a WRM audit are a documentation problem more than a security one. Payload gives us typed collections, row-level access functions, and a default admin UI we did not have to skin.

We picked Payload 3 on Next.js 15, with MongoDB for the document side (dossiers, klokuren, PDFs) and Postgres for the relational side (leerlingen, instructeurs, audit log). Two databases is a tax. It was worth it for this shape of data.

The collection that runs the WRM clock

Payload collections are TypeScript. The translation from custom post type plus ACF/meta into a typed collection is the single most useful artefact of the project, because it forces the field-by-field conversation with the client before any code runs.

// 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),
    })],
  },
}

That bewaarTot field is the WRM clock. Every dossier carries a five-year retention stamp from the moment it is created. A nightly job soft-archives anything past bewaarTot into a cold-storage collection. This is the difference between "we comply" and "we can show the inspector the row that proves we comply".

Week 1 — schema and seed

We stood up Payload behind nieuw.rijschool.local, pointed at a fresh MongoDB cluster, and ran the seed.

The seed is one Node script that reads from a read-only MySQL replica and writes into Payload's local API. No HTTP. No REST. The local API lets you call payload.create({ collection, data }) directly inside the Node process and pay no network, no auth, no validation-twice cost.

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),
    },
  })
}

The first pass produced 18,612 rows in 41 minutes. The 131 missing dossiers were leerlingen who had been deleted by a back-office admin in 2019 but whose dossiers had survived as orphans. We surfaced them in a CSV and let the owner decide. He kept them, re-attached to a synthetic uitgeschreven leerling so the audit log stayed contiguous.

Week 2 — shadow traffic

Shadow traffic is the part most teams skip and most teams regret skipping. We put a small reverse-proxy in front of WordPress that mirrored every authenticated POST and PUT into Payload as well. Reads still went to WordPress. Writes hit both.

The mirror was sixty lines of Node behind nginx. For every form submission to /wp-admin/admin-ajax.php matching a shortlist of actions (klokuur-toevoegen, dossier-update, tussentijdse-toets-uitslag), it translated to the Payload local API, wrote a parallel record, and queued a diff for the morning report.

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

By Friday of week two we had 4,118 mirrored writes and 87 mismatches. Seventy-one of those were timezone-offset bugs in the legacy code that nobody had ever noticed because the WordPress display rendered the wrong time consistently. We made Payload match the displayed time, not the stored time. The owner co-signed the decision in a Loom recording. He wanted continuity, not correctness, on dossiers already in the wild.

Warning

If shadow traffic surfaces a pre-existing bug, decide explicitly: do you preserve it for continuity, or fix it and break the audit trail? Write the decision down. Have the client co-sign. "We assumed" is not a defence in front of an inspector.

Week 3 — RDW, the instructor app, parity

The RDW koppeling was the only piece we genuinely could not break. It is the registry every instructor's pas is checked against. The legacy code was a 600-line PHP class that posted SOAP and parsed responses with simplexml. We rewrote it as a 90-line TypeScript module behind a Payload endpoint, kept the exact request envelope byte-for-byte, and replayed the previous week's calls against both implementations. Parity at 100% for 14 instructors over five working days.

The instructor app was a Next.js route group. We did not build a separate React Native shell. The instructors use it on iPads in the cars, in spots with patchy 4G. A PWA with offline klokuur-queueing and a service-worker sync covered the use case, and we did not have to ship to the App Store inside four weeks.

Week 4 — cutover Friday

We cut over on a Friday evening, 19:00 CET. Three steps.

  1. Freeze legacy writes. The mirror flipped from "WordPress primary, Payload shadow" to "Payload primary, WordPress shadow". We left WordPress live and read-only for two weeks as a safety net.
  2. Run the final delta seed. Everything written between the last full seed and the freeze got carried over: 412 dossiers, 9,841 klokuren, two new instructeurs hired that week.
  3. Flip DNS at the CDN. New TTL was 60 seconds. The old portal got a 302 to the new URL at the load balancer.

The first instructor login on Monday morning at 06:42 worked. The second one — on a 4G iPad in a Fiat 500 outside Hoorn station — also worked. No support tickets that day. Two on Tuesday, both about a font being one pixel smaller.

The artefact you leave behind

The deliverable of a migration like this is not the new portal. It is the record. Every dossier carries a legacyId, every klokuur a migratedAt, the WRM retention clock is a field rather than a folder convention. An inspector can SQL their way to an answer in 30 seconds.

That, more than the framework choice, is what kept the insurance renewal on track. WordPress 5.2 was the trigger. The data model was the project.

When we built this portal for the rijschool group in Hoorn, the script we kept rerunning was the shadow-traffic mirror — eight times across week two, each pass tightening the parity check until the diff report came back empty. If you are staring at a similar end-of-life letter for a custom WordPress build, our legacy migration work tends to start exactly here: an inventory, a typed schema, and a four-week clock.

The smallest thing you can do today: open phpMyAdmin, run SELECT COUNT(*) FROM wp_postmeta; against your production database, and write the number on a sticky note. If it's over a million and you have more than two integrations that touch external regulators, you have a shadow-traffic project, not an upgrade.

Key takeaway

Migrating a regulated WordPress portal is mostly schema work: pick a stack you can model the data in, shadow your writes for two weeks, cut over on a Friday.

FAQ

Why not just upgrade WordPress to 6.5 and PHP to 8.2?

Two of the six custom plugins used deprecated mysql_* calls. Forward-porting them would have eaten two of our four weeks, and the wp_postmeta JOIN problem would have survived the upgrade.

What does shadow traffic actually mean here?

A reverse proxy keeps WordPress as the source of truth for reads, but mirrors every authenticated write into Payload in parallel. You get a real-traffic parity test for two weeks before flipping primary.

How did you satisfy the 5-year WRM retention requirement?

Every dossier carries a bewaarTot date field set on creation. A nightly job soft-archives anything past that date into cold storage. The clock is a column, not a folder rule, so an inspector can query it directly.

Can you do this without Payload — say, Strapi or Directus?

Yes. The pattern is the framework's local-API plus typed collections plus a row-level access function. We picked Payload for the TypeScript ergonomics, but Directus and Strapi can run the same playbook.

What was the riskiest part of the four weeks?

The RDW koppeling. It was the only integration we could not break for even an hour. Byte-for-byte replay of the SOAP envelope against both implementations for a working week was the only way to be sure.

wordpressmigrationlegacy sitesphpcase studyarchitecture

Building something?

Start a project