← Blog

PHP

PHP 5.6 naar Strapi + Astro: parallelle cutover in 7 weken

Het redactie-CMS was 16 jaar oud, PHP 5.6 al jaren dood, en de NDP-feed naar 22 dagbladen mocht geen weekend stilliggen. Dit is de cutover in zeven weken.

Jacob Molkenboer· Oprichter · A Brand New Company· 20 jun 2026· 10 min
Half-open leren logboek met groen lint, koperen label, indexkaart, rubberen datumstempel, rode inktkussen op ivoor.

De hoofdredacteur stuurde ons om 23:14 op een dinsdag de screenshot: het redactie-CMS was opnieuw blijven hangen op één publish-actie. Het ging om een column van 600 woorden voor de printeditie die donderdagochtend sloot. De MySQL slow log liet een JOIN van zestien seconden zien over artikelen, auteurs en embargo_geschiedenis. Het CMS was zestien jaar oud. PHP 5.6 is sinds december 2018 end-of-life. Twee van de 22 dagbladen aan de NDP-feed hadden die ochtend geklaagd dat een story een stap had overgeslagen. De uitgever kon geen dag stoppen met drukken, en de redacteurs konden geen nieuw tool leren in één sprint.

Dit is het playbook waarmee we ze er in zeven weken vanaf hielpen, zonder één gemiste printeditie en zonder één kapotte syndicatie-push.

De randvoorwaarden

Een vakbladuitgever in Hilversum met 31 mensen. Vier titels, wekelijks print, dagelijks web, een paywall die het in-house dev-team in 2014 met de hand had gebouwd. Het CMS bevatte 142.000 artikelen vanaf 2010, plus de embargo-geschiedenis per redacteur die de uitgever onder de Auteurswet moest bewaren: wie tekende voor publicatie, op welk gezag, tegen welke embargo-tijd. Verlies die log en je verliest je verweer in een auteursrechtgeschil.

Daar bovenop een XML-feed die via de NDP naar 22 dagbladen ging, op een schema dat de ontvangende systemen al negen jaar lang parseerden. Het schema was nergens gedocumenteerd. Breek één veldnaam en je breekt op een zondag de voorpagina van een landelijke krant.

De legacy stack:

  • PHP 5.6.40 op Debian 8, op één VM bij een Nederlandse hoster.
  • MySQL 5.6 met drie custom my.cnf-tweaks waar niemand de reden meer van wist.
  • jQuery 1.7 in de editor. TinyMCE 3. SCP-deploy vanaf een developer-laptop.
  • Geen staging. Geen tests. De vorige lead developer was in 2019 vertrokken.

De uitgever wilde een moderne stack, maar wilde vooral niet in paniek raken. De opdracht was: blijf publiceren, hou de NDP-feed in de lucht, hou de embargo-log intact en juridisch houdbaar.

Waarom Strapi en Astro, en niet WordPress

We hebben drie alternatieven gewogen: WordPress met Advanced Custom Fields, een combinatie van Sanity en Next.js, en Strapi + Astro. WordPress viel af op de embargo-trail — audit hooks zijn te bouwen, maar de juristen wilden de log-tabel naast het artikel hebben, in eigendom van de uitgever, op te vragen in plain SQL. Sanity viel af op data-soevereiniteit: de juristen van de uitgever wilden de database in de EU, op hardware die ze konden aanwijzen.

Strapi gaf ons een typed content-model, een echte Postgres-database die we in Amsterdam konden draaien, lifecycle hooks voor de embargo-log, en een editor-UI die genoeg op de oude leek dat de redacteurs niet op dag één in opstand zouden komen. Astro gaf ons een lezersite die standaard naar statische HTML bouwt, met islands waar we dynamisch gedrag nodig hadden (paywall, reacties). Het marketingteam hield zijn bestaande CDN.

Week 1: archeologie van het schema

Twee developers, een hele week, geen regel code geschreven. We hebben elke kolom in elke tabel gemapt op een doelveld in Strapi of op een tombstone. De oude artikelen-tabel had 71 kolommen. Twaalf werden echt gebruikt. Drie kwamen dubbel voor onder verschillende namen. Eén — publish_state_v2 — bevatte over zestien jaar 47 verschillende waarden, inclusief typo's en vrije tekst. Die hebben we teruggebracht tot negen echte states (draft, in_review, embargoed, scheduled, published, retracted, archived, spiked, syndicated_only) en gegoten in een CSV die het legacy-team regel voor regel ondertekende.

De belangrijkste deliverable was geen database-diagram. Het was een geschreven lijst met de twaalf businessregels die de embargo-trail moest bewaren. Dingen als: "als een artikel wordt ingetrokken, moet het oorspronkelijke publicatiemoment in de log blijven staan, nooit verwijderd." We hebben de huisjurist van de uitgever het document laten ondertekenen. Die handtekening werd de spec.

Week 2: Strapi-modellering en de embargo-log

We modelleerden drie content types: Article, Author, EmbargoLog. De eerste twee spreken voor zich. De derde is de ruggengraat van de migratie. Elke statuswijziging op een artikel schrijft een onveranderlijke regel.

// src/api/article/content-types/article/lifecycles.js
module.exports = {
  async beforeUpdate(event) {
    const { data, where } = event.params;
    const before = await strapi.entityService.findOne(
      'api::article.article',
      where.id,
      { fields: ['status', 'embargo_at'] }
    );
    const changed =
      before.status !== data.status ||
      String(before.embargo_at) !== String(data.embargo_at);
    if (!changed) return;
    await strapi.entityService.create('api::embargo-log.embargo-log', {
      data: {
        article: where.id,
        actor: event.state.user?.id ?? null,
        from_status: before.status,
        to_status: data.status,
        embargo_at: data.embargo_at,
        recorded_at: new Date().toISOString(),
      },
    });
  },
};

De embargo_log-tabel is op databaseniveau append-only. We hebben UPDATE en DELETE ingetrokken voor de Strapi-rol, en de redacteur-rol heeft er helemaal geen directe toegang toe. De enige schrijver is de lifecycle hook. De enige lezer via de UI is een custom Strapi-plugin die per artikel de volledige keten toont, van draft tot de huidige state.

Let op

Als je een embargo-log of audit-log migreert naar een nieuw systeem, laat de nieuwe ORM dan nooit beslissen of een regel muteerbaar is. Trek het recht in op database-rolniveau. ORM's veranderen. Toezichthouders niet.

Week 3: ETL-droogloop op een kopie

We namen op vrijdagavond een dump van de productie-MySQL, restoreden hem op een dev-bak en draaiden de hele ETL er tegenaan. Het script is één Node-proces dat artikelen in chunks van 500 streamt, velden mapt en doorschrijft naar de Strapi REST API, met de lifecycle hooks uitgeschakeld. We willen niet 142.000 regels in de embargo-log voor de import — die events hebben niet op het nieuwe platform plaatsgevonden.

In plaats daarvan materialiseren we de historische embargo-trail rechtstreeks: we lezen embargo_geschiedenis, normaliseren zestien jaar aan timestamps naar UTC, hangen elke regel aan het juiste article-id en doen een bulk-insert in de embargo-log met de vlag imported_from_legacy: true. We kunnen, desnoods voor de rechter, aantonen welke regels uit een record van 2010 zijn herbouwd en welke live door het nieuwe systeem zijn vastgelegd.

De eerste droogloop duurde 11 uur. Bij de derde pass was het 73 minuten. Het verschil: bulk-inserts in plaats van per-rij HTTP, en de search-indexes laten vallen tijdens de import en daarna opnieuw opbouwen.

Week 4: parallel publiceren

Dit deel kun je niet overhaasten. De redacteurs bleven publiceren in het legacy CMS. Elke schrijfactie liep door een dunne PHP-shim die we aan de legacy-functie publish_artikel() toevoegden:

<?php
function publish_artikel(array $row): void {
    legacy_insert($row);
    if (getenv('STRAPI_DUAL_WRITE') === '1') {
        try {
            strapi_post('/api/articles', map_legacy_to_strapi($row));
        } catch (Throwable $e) {
            error_log("strapi mirror failed for {$row['id']}: " . $e->getMessage());
            // never block the legacy write
        }
    }
}

Een nachtelijke diff-job trok beide kanten op en rapporteerde elke afwijking in een Slack-channel die de migratie-lead daadwerkelijk las. De eerste twee dagen vonden we 38 mismatches, allemaal in de datumafhandeling rond middernacht (CET versus UTC). Op dag vijf was de diff schoon. De redacteurs wisten niet dat de dual-write bestond. Ze publiceerden zoals altijd. Wij keken mee.

Week 5: NDP-syndicatie cutover

De NDP-koppeling was het engste deel van het project, omdat de afnemers niet wijzelf zijn. 22 dagbladen, elk met hun eigen ingestion-pipeline, die exact hetzelfde XML-schema verwachten dat de uitgever sinds 2017 produceerde. Eén ontbrekend attribuut, één hernoemde tag, en er staat een gat op de voorpagina van een echte krant op zondagochtend.

We hebben de feed niet gerefactord. We hebben hem byte voor byte opnieuw geïmplementeerd. De nieuwe Astro-site bedient een route die de legacy-XML produceert, character voor character compatibel, gevalideerd tegen een sample van 200 gearchiveerde feeds. We hebben de nieuwe feed een week parallel laten draaien op een staging-URL waar de NDP-ontvanger één van de 22 kranten als canary naartoe wees. Toen die krant drie schone edities op de nieuwe feed had gedrukt, hebben we op een zondagmiddag de overige 21 omgezet.

Eén ding leerden we op de harde manier: een paar ontvangende systemen waren gevoelig voor de volgorde van XML-attributen. De XML-spec zegt dat attribuutvolgorde niet significant is. Echte parsers vinden van wel. We hebben de attribuutvolgorde bevroren zodat hij overeenkwam met de legacy-output, en een CI-test toegevoegd die dat bij elke deploy opnieuw controleert. Als je een feed vervangt die andere systemen consumeren, behandel het wire format als het contract: reproduceer de bytes, niet de intentie.

Week 6: de lezersite op Astro

Dit was de makkelijke week. Astro bouwt de publieke site uit de Strapi content-API, grotendeels statisch, met islands voor de paywall-check en de reacties. De vorige lezersite zat gemiddeld op 2,1 seconden tot first contentful paint op een 4G-verbinding. De nieuwe zit op 0,4. We hebben in de lanceringsaankondiging geen alinea over performance geschreven — de lezers merkten het, de redacteurs merkten het, en dat was genoeg.

Het marketingteam migreerde zijn tracking en SEO-redirects in twee dagen. Elke legacy-URL hielden we in leven met een 301 vanuit de redirect map van Astro. /artikel/12345-titel-slug resolvt nog, zes jaar aan inbound links werken nog.

Week 7: sunset

Op de laatste zondag zetten we het legacy CMS op read-only. De redacteurs publiceerden al negen dagen uitsluitend in Strapi; ze merkten de freeze niet eens. We lieten de legacy-MySQL 90 dagen draaien op een private VM als read-only archief, en zetten daarna een snapshot weg in cold storage. Die cold storage kost de uitgever €4 per maand. Het ondertekende dossier van de huisjurist ligt naast de snapshot. Als een journalist of jurist ooit moet vaststellen wat in 2014 de embargo-state van een artikel was, kunnen we het binnen een uur overhandigen.

Wat we anders zouden doen

Twee dingen. Ten eerste hebben we het tijdzone-werk onderschat. Zestien jaar aan DATETIME-kolommen zonder timezone-kolom betekent per regel inferen of het CET of CEST was. We schreven in week drie een deterministische resolver; die had in week één moeten staan, getest tegen een bekende sample van 100 artikelen, vóór we ook maar iets anders aanraakten.

Ten tweede hadden we de byte-voor-byte XML-feed eerder moeten bouwen dan het content-model. Het XML-schema bleek het lastigste externe contract. Toen we precies wisten wat het nodig had, volgde het Strapi content-model er als vanzelf uit. We deden het andersom en raakten twee dagen kwijt aan het hernoemen van velden.

Toen we voor deze uitgever de parallel-publish shim bouwden, was de hefboom die ons de meeste tijd opleverde de saaie, nachtelijke diff-job tussen oud en nieuw — niet sexy, maar ze ving elke regressie voordat een redacteur hem zag. Dat soort onzichtbare engineering is het verschil tussen een legacy-migratie die op schema landt en eentje die in brand staat.

Heb je een CMS dat nog op een PHP-versie draait die al jaren dood is: begin niet met de nieuwe stack. Begin met één query: tel de rijen in de tabel die je juridische audit-trail bevat, en lees met de hand de eerste tien en de laatste tien. Bestaat die tabel helemaal niet, dan is dat je eerste sprint, niet de migratie.

Kern

Migreer eerst de juridische audit-trail, dual-write een week lang, en bouw de syndicatie-feed byte voor byte na. De rest is gewoon CMS-werk.

FAQ

Hoe lang duurt een volledige migratie van PHP 5.6 naar Strapi?

Voor een uitgever met zo'n 142.000 artikelen en een actieve syndicatie-feed: reken op zes tot acht weken doorlooptijd met twee senior engineers. Kleinere corpora zonder syndicatie: drie tot vier weken.

Kun je het oude en het nieuwe CMS parallel draaien zonder de redactie in de war te brengen?

Ja. De redactie blijft publiceren in het oude tool, terwijl een write-through shim elke save spiegelt naar het nieuwe systeem. Ze switchen pas van UI als een nachtelijke diff-job een hele week schoon draait.

Hoe migreer je een audit-log zonder de juridische bewijskracht te verliezen?

Markeer elke historische regel met imported_from_legacy, trek UPDATE en DELETE in op database-rolniveau zodat de ORM ze niet kan muteren, en hou een ondertekend field-mapping-document bij je huisjurist.

Waarom geen WordPress voor een uitgever met strenge audit-eisen?

WordPress werkt prima voor veel uitgevers. Voor een Auteurswet-embargo-log is het pad via custom plugins broos over upgrades heen. Een typed content-model in Strapi of iets vergelijkbaars overleeft plugin-churn beter.

phpmysqllegacy sitesmigrationarchitecturecase study

Iets bouwen?

Start een project