← Blog

Migration

Van PHP 5.4 naar Payload CMS: shadow-cutover in 7 weken

Een 16 jaar oud PHP 5.4-portaal, 14.200 archiefpagina's, een Mollie iDEAL-mandaat dat moest meeverhuizen, en een harde deadline. Zo landde de shadow-cutover in zeven weken.

Jacob Molkenboer· Oprichter · A Brand New Company· 18 jun 2026· 10 min
Open leren logboek met messing sleutel op crème kaart, groen tabje, ijzeren label aan linnen touw op ivoor papier.

De mysqldump kwam binnen op 4,1 GB. Zevenentachtig tabellen, drie ervan gespeld met een typefout die iemand in 2011 had besloten niet te fixen omdat "queries er al naar verwijzen." Wachtwoorden opgeslagen als unsalted SHA-1. Een Mollie-integratie gebouwd tegen de v1 API, waarvan Mollie zelf ergens rond 2018 al had gezegd: stop ermee. De host had de klant een harde knip gegeven: de FreeBSD 9-jail met PHP 5.4 ging de laatste vrijdag van Q2 uit.

De klant is een 25-koppige scheepvaart-uitgever in Groningen, drie eeuwen havennieuws, veertienduizend archiefpagina's, en grofweg achtduizend betalende abonnees wier maandelijkse incasso de hypotheek betaalt op het kantoor boven de Noorderhaven. Het portaal draaide sinds 2010 op dezelfde custom PHP-stack. De auth-laag was in negen jaar niet aangeraakt. Twee van de drie oorspronkelijke developers waren overleden, en de derde was naar Noorwegen verhuisd en weigerde er beleefd over te praten.

Zeven weken. Nieuwe stack: Payload CMS op Postgres, Next.js 15 ervoor, Mollie's moderne recurring-mandaat-flow aan de kassa. Geen her-autorisatie voor bestaande abonnees. Geen URL-wijzigingen. Geen verlies van archief. Zo zagen die zeven weken er werkelijk uit.

Week 0: lezen voor je schrijft

De zwaarste week van elke oude-PHP-migratie is degene die je vóór week 1 doet, als je de codebase en de database leest en de neiging weerstaat de nieuwe alvast te schrijven. Wij deden dit zes werkdagen lang. Aan het eind hadden we een uitgeprinte dependency-graph, een lijst van elke cron, en een document van één pagina dat we het surface noemden: het contract dat het legacy systeem feitelijk aan de buitenwereld blootstelde.

  • URL-surface: 14.200 archief-URL's in de vorm /zeebrieven/{jaar}/{nummer}/{slug}.html, allemaal door Google geïndexeerd en gelinkt vanaf een tiental maritieme fora.
  • Auth-surface: een session-cookie genaamd SHPSESS, gekoppeld aan een sessies-tabel die sinds 2017 niet meer geTRUNCATEd was.
  • Payments-surface: één Mollie-mandaat per abonnee, opgeslagen als mandaat_id op de abonnees-tabel, plus een nachtelijke cron die het SEPA-bestand van de vorige dag afstemde.
  • Leesgeschiedenis-surface: een INSERT bij elke pageview in paginabezoek. 91 miljoen rijen. Geen primary key die die naam waard is. Daar komen we op terug.

We legden het surface-document voor aan de klant en stelden één vraag: "Welke hiervan mogen we veranderen, en welke niet?" Dat antwoord weegt zwaarder dan welk architectuurdiagram dan ook. We mochten SHPSESS aanpassen (niemand was ervan afhankelijk). We mochten de archief-URL's niet aanpassen (veertig procent van het organische verkeer). We mochten zeker niemand vragen iDEAL opnieuw te doen.

Week 1: schema-mapping naar Payload-collecties

Het datamodel van Payload bestaat uit collecties van typed documents, niet uit genormaliseerde joins. 87 MySQL-tabellen terugbrengen tot een werkbare set collecties is de stap waarin de meeste teams gaan overdenken. Wij deden het in één whiteboard-sessie.

De regels die we volgden:

  1. Alles wat de redactie aanraakt wordt een collectie: zeebrieven, auteurs, rubrieken, uitgaven.
  2. Alles wat met facturatie te maken heeft krijgt een eigen collectie met een strakke access policy: abonnees, mandaten, incasso-runs.
  3. Alles wat append-only en high-volume is, oftewel die 91 miljoen rijen leesgeschiedenis, leeft niet in Payload. Dat gaat naar een apart Postgres-schema met fatsoenlijke indexen, bevraagd via een custom Payload-endpoint.

Drie collecties. Eén side table. Eén service. We schreven de Payload-config en seedden op de tweede dag met duizend rijen representatieve data, en lieten de redactie op vrijdag een kliktest doen. De hoofdredacteur wees precies één ontbrekend veld aan (een vlagstaat-enum voor het scheepsregister) en dat hadden we in tien minuten toegevoegd. Die meeting heeft zichzelf in week zes tien keer terugverdiend.

Week 2: de Mollie-mandaat-handshake

Dit is het stuk waar iedereen bang voor is, en terecht. Het portaal had 7.914 actieve doorlopende abonnementen die via Mollie's iDEAL-naar-SEPA-mandaat-flow werden geïncasseerd. Zouden we ook maar een van die mensen om her-autorisatie vragen, dan verloren we tussen de vijf en vijftien procent van het bestand binnen een nacht. De CFO had daar geen misverstand over laten bestaan.

Het goede nieuws, waar te weinig over gepraat wordt: een Mollie-mandaat is portable tussen je eigen API-integraties. Het mandate_id is stabiel. Het bankzijdige mandaat (de SEPA-mandaatreferentie die je daadwerkelijk aan de bank van de klant toont) leeft bij Mollie, niet in jouw code. Je kunt de hele integratie opnieuw bouwen op dezelfde set mandaten, zolang je customer IDs nog kloppen.

Dus dat hebben we gedaan. We lazen elk mandaat_id uit de oude database, koppelden het aan Mollie's v2-customer-record (de IDs waren ongewijzigd; de v1-deprecation had ze nooit verwijderd), en zetten ze allebei op de nieuwe abonnees-collectie.

// payload/collections/Abonnees.ts
export const Abonnees: CollectionConfig = {
  slug: 'abonnees',
  access: { read: isOwnerOrEditor, update: isOwnerOrEditor },
  fields: [
    { name: 'email', type: 'email', required: true, unique: true },
    { name: 'mollieCustomerId', type: 'text', required: true, index: true },
    { name: 'mollieMandateId',  type: 'text', required: true, index: true },
    { name: 'mandaatStatus', type: 'select', options: ['valid', 'pending', 'invalid'] },
    { name: 'legacyAbonneeNr', type: 'number', index: true }, // for the 301s
    { name: 'incassoIntervalMaanden', type: 'number', defaultValue: 1 },
  ],
}

Voordat we ook maar één nieuwe charge schreven, draaiden we een eenmalig script dat GET /v2/customers/:id/mandates aanriep voor alle 7.914 klanten en controleerde of elk mandaat terugkwam met status: "valid". Eenenveertig kwamen invalid terug: opgezegde rekeningen, overleden abonnees, één mandaat dat in 2014 nooit netjes was getekend. Die eenenveertig hebben we gevlagd zodat customer service ze met de hand kon oppakken. De rest bleef gewoon betalen, zonder ooit te merken dat de backend was vervangen.

Let op

Roep Mollie's POST /v2/payments met sequenceType: recurring niet aan voordat je het mandaat hebt teruggelezen en hebt bevestigd dat status: valid is. Een mislukte eerste recurring charge kost je €0,30, een mailtje van customer service en een dreuntje in het vertrouwen dat je in week twee niet kunt gebruiken.

Week 3: de archief-import en URL-behoud

14.200 archiefpagina's, allemaal platte HTML op disk, allemaal crawlbaar, allemaal vanaf elders gelinkt. Drie taken: de content schoon eruit halen, in Payload zetten, en dezelfde URL's vanuit Next.js serveren met dezelfde response-codes.

De extractie was een Node-script dat node-html-parser losliet op de flat-file-directory. Elk bestand mapten we op één zeebrieven-document, hielden { jaar, nummer, slug, titel, publicatiedatum, lichaam, auteur } vast, en uploadden inline afbeeldingen naar de Payload media-collectie. De body sloegen we op als Lexical JSON, niet als HTML, omdat we wilden dat de redactie ook historische stukken kon redigeren en we niet twee formats in hetzelfde veld wilden.

De URL-regel was één route in de Next.js app router:

// app/zeebrieven/[jaar]/[nummer]/[slug]/page.tsx
export default async function ArchiefPagina({ params }: { params: Params }) {
  const res = await payload.find({
    collection: 'zeebrieven',
    where: {
      and: [
        { jaar:   { equals: Number(params.jaar) } },
        { nummer: { equals: Number(params.nummer) } },
        { slug:   { equals: params.slug.replace(/\.html$/, '') } },
      ],
    },
    limit: 1,
  })
  if (!res.docs[0]) notFound()
  return <Archiefartikel doc={res.docs[0]} />
}

De afsluitende .html was belangrijk. Google had de .html geïndexeerd. We hielden 'm. De Next.js-route accepteerde de extensie en knipte 'm eraf vóór de database-lookup. Geen 301-keten, geen rewrite-rules aan de edge: één route, één query, identieke URL's.

Week 4: leesgeschiedenis zonder de tabel van 91 miljoen rijen

De legacy paginabezoek-tabel was ongeïndexeerd, onbestuurbaar en groeide met ongeveer vier miljoen rijen per maand. Het productteam gebruikte hem voor precies één ding: "laat zien wat ik laatst heb gelezen."

We hebben hem niet gemigreerd. We zijn begonnen met een nieuwe tabel in een apart Postgres-schema, gepartitioneerd per maand, met een composite index op (abonnee_id, gelezen_op DESC). We schreven een Payload-endpoint op /api/leesgeschiedenis dat de laatste 50 reads teruggaf. De oude data hebben we naar S3 gearchiveerd als maandelijkse Parquet-bestanden, en de redactie verwezen we naar een Metabase-dashboard mocht er ooit naar gevraagd worden.

Niemand heeft erom gevraagd.

Week 5: shadow traffic op één edge

Shadow traffic is de techniek waarbij je een fractie van het echte productieverkeer naar het nieuwe systeem stuurt, de responses vergelijkt met die van het oude, en het verschil naar een log schrijft. Je toont de nieuwe response niet aan de gebruiker. Je test het nieuwe systeem alleen tegen de realiteit.

We deden het aan de CDN-edge met een kleine worker:

// edge worker (Cloudflare-style)
export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url)
    const oldResp = await fetch('https://legacy.scheepspublisher.nl' + url.pathname, req)
    if (Math.random() < 0.10) {
      ctx.waitUntil((async () => {
        const newResp = await fetch('https://new.scheepspublisher.nl' + url.pathname, req)
        await env.SHADOW_LOG.put(crypto.randomUUID(), JSON.stringify({
          path: url.pathname,
          oldStatus: oldResp.status,
          newStatus: newResp.status,
          oldHash: await sha1(await oldResp.clone().text()),
          newHash: await sha1(await newResp.clone().text()),
        }))
      })())
    }
    return oldResp
  },
}

Tegen het einde van week 5 hadden we 1,4 miljoen shadow requests gelogd. 99,6% matchte op statuscode. De 0,4% die niet matchte viel in drie groepen: malformed legacy URLs waarvoor de oude PHP een 200-met-foutpagina serveerde (wij gaven een schone 404 terug), Googlebot die naar /wp-login.php aan het proben was (wij gaven 404 terug, de legacy een custom error-template op 200), en een handvol gepagineerde archief-listings waar de legacy off-by-one-paginatie had. Alleen die derde groep deed ertoe, en die was in een middag gefixt.

Week 6: canary, deze keer op echte gebruikers

Met schone shadow-logs zetten we dezelfde edge-worker in canary-modus: 5% van de echte gebruikers kreeg daadwerkelijk de nieuwe response, de rest bleef op legacy. We hielden Mollie-webhook-events, login-success-rate en time-to-first-byte in de gaten. Na 72 uur op 5% zonder afwijkingen gingen we naar 25%. Na nog 48 uur naar 50%.

De enige verrassing uit de echte wereld kwam op 25%: één Sectigo TLS-intermediate was gepinned in een oude Android-app van de redactie. Niemand had ons over die app verteld. We rolden de canary voor die user-agent terug naar 0, zetten op een aparte hostname een stub neer zodat de app bleef werken, en duwden de canary weer omhoog. Twee uur verminderde dienstverlening voor vier mensen, geen van hen abonnee.

Week 7: cutover, en wat je om 23:00 doet

Om 23:00 op de laatste dinsdag zetten we de edge-worker op 100% nieuw. Om 23:05 hadden we via synthetische monitors bevestigd dat inloggen, betaalde-content-toegang, mandaat-lookup en archief-routing allemaal schoon waren. De legacy-bak bleef nog zeven dagen online, in read-only modus, achter een private hostname. Daarna trok de host de stekker eruit.

De ochtend erna stuurde de hoofdredacteur één Slack-bericht: "De zoekfunctie is sneller. Niemand heeft gebeld." Niemand had gebeld. Dat is in de uitgeverij de enige succesmaat die telt.

Onthouden

Het Mollie-mandaat is portable. De URL-surface is heilig. De leesgeschiedenis mag stilletjes worden gearchiveerd. Al het andere is gewoon een Payload-collectie en een Next.js-route.

Drie dingen die we de volgende keer anders doen

We hebben in week 1 te lang aan de redactie-UI gepoetst. De defaults van Payload zijn zo goed dat de redactie vanaf dag één tevreden was. We hebben tot in week drie velden zitten polijsten die niemand gebruikte. De volgende keer leveren we de ongefilterde Payload-admin op en passen we pas aan op basis van echte klachten.

We hadden de SEPA-reconciliatie-tests vóór de Mollie-webhook-handler moeten schrijven. We schreven ze erna, toen één batch-retourbestand met een ongebruikelijke R-transactiecode ons verraste. Mollie's documentatie over recurring payments heeft de volledige lijst. Lees 'm voordat je een regel webhook-code schrijft.

We hadden de lead van customer service al in week 0 erbij moeten halen. Zij wist allang van die eenenveertig kapotte mandaten. Het was haar alleen nooit gevraagd.

De audit van vijf minuten die je vandaag kunt doen

Zit je zelf op een oud PHP-en-MySQL-portaal en heeft de host je nog geen deadline gegeven, dan is het eerste wat je doet niet een stack kiezen. Het is je eigen surface-document schrijven. Pak één pagina. Schrijf je URL-surface, je auth-surface, je payments-surface en je grootste datatabel op. Voor elk één zin: "Mogen we deze aanpassen?" Dat document vertelt je hoeveel werk de migratie is. Alles wat daarvóór komt is speculatie.

Toen we deze legacy-migratie voor het team in Groningen deden, was het ding waar we tegenaan liepen de overgang van Mollie v1 naar v2 met behoud van de mandaten. We hebben het opgelost door elk mandaat eerst terug te lezen bij Mollie voordat we ook maar één charge omgooiden, en die eenenveertig invalid mandaten te behandelen als customer-service-taak, niet als code-taak.

Kern

Het Mollie mandate-ID is portable tussen je eigen integraties: lees 'm terug, verifieer 'm, en bouw eromheen opnieuw zonder ook maar één her-autorisatie af te dwingen.

FAQ

Waarom Payload CMS en niet WordPress of Strapi voor een abonnementenportaal?

De typed collections en code-first access rules van Payload maken het rechtdoorzee om betaalde content af te schermen. Het plugin-ecosysteem van WordPress wordt al snel je security-surface; dat wilden we zelf in handen houden.

Kun je echt Mollie recurring-mandaten behouden bij een complete integratie-rebuild?

Ja. Het mandate-ID is stabiel aan Mollie's kant. Lees het terug via de v2 customers API, bevestig dat de status valid is, en bestaande abonnees krijgen nooit een her-autorisatie-prompt te zien.

Wat als legacy URLs eindigen op .html of een rare casing hebben?

Hou ze. Match de exacte vorm in je Next.js-route en knip de extensie er in code af. Een 301-keten kost crawl-budget; een letterlijke route kost één regel.

Zijn die twee extra weken shadow traffic het waard ten opzichte van een harde overstap?

Ja, mits je gedisciplineerd bent. De shadow-fase vindt de routes waar de legacy een 200 teruggaf op nonsens. Sla 'm over en die routes worden je eerste echte gebruikersincident.

migrationlegacy sitesphpmysqlarchitecturecase study

Iets bouwen?

Start een project