— Artikel — № 087

087 —Migration

Custom PHP CMS naar WordPress-shell: rebuild in vier weken

Een Nederlands bureau had vier weken om een PHP CMS uit 2009 naar WordPress te tillen zonder één van 14.000 URL's te verliezen. Zo deden ze het.

Bovenaanzicht stilleven op linnen: handgetekende URL-redirectkaart op ruitjespapier, manilamap, envelop met lakzegel.
Hero · gestileerd stilleven№ 087

Een Nederlands bureau waarmee we samenwerken stuurde afgelopen maart om 23:41 een Loom. Hun klant, een regionale zorginstelling, had een custom PHP CMS geërfd dat in 2009 was gebouwd door een leverancier die niet meer bestaat. Zo'n 14.000 geïndexeerde URL's, vier content types, een redactie van zes die weigerde iets anders dan WordPress te leren, en een PHP 8.2-deadline die hen aankeek vanaf de statuspagina van hun hostingmaatschappij.

De briefing was van het ongemakkelijke soort. Ze wilden WordPress voor het admin-gedeelte. Ze mochten geen enkele URL verliezen. Het custom PHP CMS bevatte tien jaar redactioneel werk in tabellen die in niets leken op wp_posts. En het budget dekte vier weken, geen vier maanden. Die vier weken waren ook niet willekeurig: de hostingmaatschappij had een harde datum gegeven voor het uitfaseren van PHP 7.4, en het verouderde CMS gebruikte create_function() in twee hot paths die op 8.x zouden falen.

We hebben dit soort legacy site-rebuilds vaak genoeg gedaan dat het antwoord dinsdag al duidelijk was: een dunne WordPress-shell over de legacy database, met een SQL-brug ertussen. Dit is de post-mortem.

De vorm van het beest uit 2009

De eerste week was lezen. Het CMS had geen documentatie, geen tests, en er was geen ontwikkelaar meer over die zich er iets van herinnerde. We haalden de database door een paar audit-queries en printten het schema als wandposter.

De relevante tabellen zagen er zo uit:

cms_pages       (id, parent_id, slug, title, body, status, ts_created, ts_updated)
cms_articles    (id, category_id, slug, title, intro, body, author, published_at)
cms_sections    (id, page_id, position, kind, payload)
cms_categories  (id, slug, name, parent_id)
cms_users       (id, email, password_md5, role)

Twee dingen vielen op. Ten eerste de slug-strategie: cms_pages gebruikte geneste paden die op runtime werden opgebouwd door recursief parent_id te lopen. Dat betekende dat een pagina op /zorg/locaties/utrecht-noord/ leefde, maar als platte rij was opgeslagen met slug = "utrecht-noord". Ten tweede was cms_sections.payload een geserialiseerde PHP-array, geen JSON, omdat PHP 5.2 de wereld was toen dit werd gebouwd.

De audit-queries zelf waren saai maar dragend. SHOW TABLE STATUS voor row counts. SELECT COUNT(DISTINCT slug) FROM cms_pages WHERE parent_id IS NULL voor het aantal top-level URL's. EXPLAIN op een homepage-render om te zien wat het legacy script de database daadwerkelijk vroeg. Dat antwoord was lelijk: 47 queries op de homepage, geen index op cms_articles.published_at, en een functie genaamd build_breadcrumb() die in PHP recursief werkte in plaats van in SQL en bij elke pageview opnieuw draaide.

Als we dit op de naïeve manier hadden geïmporteerd in wp_posts, hadden we de URL-structuur verloren, elke interne link gebroken, en een redactie gedwongen om vanaf dag één een nieuw contentmodel te leren. Geen daarvan was acceptabel. Dus deden we het omgekeerde. We lieten de legacy tabellen met rust en bouwden een vertaler.

De dunne shell-architectuur

Het ontwerp waar we op uitkwamen:

  • WordPress draait naast de legacy database, in een verse installatie met eigen wp_*-tabellen.
  • De legacy cms_*-tabellen blijven precies zoals ze zijn. Geen destructieve migratie, geen schemaherschrijving.
  • Een set MySQL-VIEWs ontsluit de legacy content alsof het een custom post type is, met de joins en het slug-walken in SQL gedaan.
  • Een kleine mu-plugin leert WordPress die views als WP_Post-objecten te lezen op de front-end, terwijl writes via handgeschreven repository-methodes teruggaan naar de legacy tabellen.
  • De redactie ziet alleen ooit de WordPress-admin. Onder de motorkap is elke save een custom save_post-hook die het legacy schema bijwerkt.

De winst was dat het rollback-verhaal triviaal bleef. Als er op cutover-dag iets brak, konden we Apache terugwijzen naar de oude document root en bleef het legacy CMS gewoon draaien. De legacy database was nooit het migratiedoel. Het was de bron van waarheid, punt.

De SQL-brug

Dit is het stuk dat de rest mogelijk maakte. We bouwden drie MySQL-views die het legacy schema vertaalden naar iets dat WordPress kon consumeren.

De eerste view liep de parent chain af om het volledige URL-pad te materialiseren:

CREATE OR REPLACE VIEW v_page_paths AS
WITH RECURSIVE chain AS (
  SELECT id, parent_id, slug, CAST(slug AS CHAR(1024)) AS full_path
  FROM cms_pages WHERE parent_id IS NULL
  UNION ALL
  SELECT p.id, p.parent_id, p.slug,
         CONCAT(c.full_path, '/', p.slug) AS full_path
  FROM cms_pages p
  JOIN chain c ON p.parent_id = c.id
)
SELECT id, slug, full_path FROM chain;

Recursieve CTE's vereisen MySQL 8 (zie de MySQL-referentie). De legacy host zat op 5.7. Dus upgraden we eerst MySQL, wat op zichzelf drie dagen kostte en een apart verhaal is.

De tweede view vormde pagina's om naar iets dat WordPress kon lezen:

CREATE OR REPLACE VIEW v_wp_pages AS
SELECT
  p.id                AS ID,
  1                   AS post_author,
  p.ts_created        AS post_date,
  p.ts_updated        AS post_modified,
  p.title             AS post_title,
  p.body              AS post_content,
  vp.full_path        AS post_name,
  IF(p.status = 1, 'publish', 'draft') AS post_status,
  'legacy_page'       AS post_type
FROM cms_pages p
JOIN v_page_paths vp ON vp.id = p.id;

De derde deed hetzelfde voor artikelen, met een join via cms_categories zodat de URL-prefix uitkwam op /nieuws/{category-slug}/{article-slug}.

We discussieerden een halve dag over materialized versus gewone views. MySQL heeft geen native materialized views, en de productiedatabase bevatte zo'n 22.000 rijen verdeeld over cms_pages en cms_articles samen. Een enkele CTE-walked lookup kwam warm uit op zo'n 4ms. We hielden ze als gewone views en leunden op een fragment cache in WordPress voor elke view die meer dan drie joins raakte. De cold-cache page render zakte van 870ms op het legacy script naar 110ms op de brug, wat iedereen in de kamer verraste.

Dit waren read-only views. We hebben er nooit doorheen geprobeerd te schrijven. Writes liepen via gewone PHP-repository-classes die het legacy schema uit hun hoofd kenden.

De .htaccess-laag voor URL-behoud

De SEO-consultant van de klant had een lijst van zo'n 200 high-value URL's die ze niet wilden verliezen. De overige 13.800 moesten ook hun vorm behouden, maar die 200 waren degene met een kwartaalreview eraan vast.

De nieuwe WordPress-installatie leefde op dezelfde document root. Apache moest kiezen tussen een request sturen naar het legacy script (tijdens het transitievenster) of naar index.php voor WordPress. De mod_rewrite-regels zagen er ongeveer zo uit:

# Legacy assets stay where they were
RewriteRule ^uploads/legacy/(.*)$ /uploads/legacy/$1 [L]

# Editorial preview tokens keep hitting the old script during cutover
RewriteCond %{QUERY_STRING} (^|&)preview_token=
RewriteRule ^(.*)$ legacy/index.php [L]

# Specific high-value redirects (200 entries, generated from a CSV)
RewriteRule ^oude-url/specifiek$ /nieuwe-url/specifiek [R=301,L]

# Everything else: WordPress
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]

De volgorde van de regels is hier belangrijker dan mensen onthouden. Apache evalueert van boven naar beneden en de eerste match met [L] stopt de verwerking, dus de legacy preview-token-regel moest boven de WordPress catch-all staan. Iedereen die ooit RewriteRule . /index.php [L] boven zijn static-asset-bypass heeft gezet en elke PNG de homepage zag teruggeven kent het gevoel.

We hielden de legacy uploads/-directory intact, gemapt op hetzelfde pad waar hij altijd had geleefd. Geen van de twaalf jaar aan redactionele afbeeldingslinks verrotte.

WordPress leren om de brug te lezen

De mu-plugin was de kleinste component. Zo'n 180 regels. De kern was een posts_request-filter die elke query onderschepte die om post_type=legacy_page vroeg en die herschreef naar v_wp_pages in plaats van wp_posts.

add_filter('posts_request', function ($sql, $query) {
    if ($query->get('post_type') !== 'legacy_page') {
        return $sql;
    }
    return str_replace(
        $GLOBALS['wpdb']->posts,
        'v_wp_pages',
        $sql
    );
}, 10, 2);

Het is een afschuwelijke hack en we houden ervan. WordPress' eigen querylaag doet het zware werk (paginering, sortering, basic WHERE) en wij wisselen alleen op het laatste moment de tabelnaam om. De view doet de rest. Het posts_request-filter bestaat in core precies omdat de maintainers wisten dat iemand dit ooit zou doen.

Terugschrijven via de admin

Reads waren de makkelijke helft. Writes moesten via PHP, omdat schrijfbare joined views een valkuil zijn en omdat het legacy schema constraints had die de view-laag niet kon uitdrukken (een cms_pages-insert heeft bijvoorbeeld een bijbehorende cms_sections-rij nodig).

Het write-pad haakte save_post voor het custom post type en riep een repository-class aan:

add_action('save_post_legacy_page', function ($post_id, $post) {
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    if (wp_is_post_revision($post_id))            return;

    $repo = new LegacyPageRepository($GLOBALS['wpdb']);
    $repo->upsert([
        'id'         => (int) $post_id,
        'title'      => $post->post_title,
        'body'       => $post->post_content,
        'status'     => $post->post_status === 'publish' ? 1 : 0,
        'ts_updated' => current_time('mysql'),
    ]);
}, 10, 2);

De repository-class was zo'n 90 regels per content type. De interessantste truc was dat we de WordPress post ID identiek hielden aan de legacy row ID. Botsingen waren het voor de hand liggende risico: WordPress neemt auto-increment integers aan voor zijn eigen posts, en we wilden niet dat een nieuwe redactionele pagina bovenop een legacy pagina belandde. We reserveerden vooraf een ID-range boven elke plausibele cms_pages.id-waarde door na de installatie ALTER TABLE wp_posts AUTO_INCREMENT = 1000000 te zetten. Legacy ID's bleven onder het miljoen. WordPress-native posts begonnen daarboven. De brug-views mappen p.id rechtstreeks naar de ID-kolom, en wp_postmeta bleef leeg voor legacy content omdat niets in de editor daar naartoe schreef.

Proberen een SQL VIEW schrijfbaar te maken over joins heen is hoe weekends eindigen. Het repository-pattern verschoof die complexiteit naar PHP, waar het in elk geval leesbaar was.

Wat brak op cutover-dag

We schakelden om 06:00 over op een dinsdag. Om 06:14 renderde de homepage. Om 06:22 logde de redactie in op de WordPress-admin en raakte in paniek omdat de rich-text editor weigerde afbeeldingen te laden.

Vier echte problemen kwamen boven in de eerste 24 uur:

  1. Afbeeldings-URL's in legacy body-velden. Oude content sloeg absolute URL's op zoals http://www.client.nl/uploads/2014/foo.jpg, met de kale www. De nieuwe site was HTTPS-only met een redirect, wat de editor als untrusted behandelde. Fix: een eenmalige SQL-update die protocol en host normaliseerde over cms_pages.body en cms_articles.body.
  2. Character encoding. De legacy database was gedeclareerd als latin1 maar had UTF-8 bytes erin geduwd (het klassieke geval). WordPress querde hem als utf8mb4. De helft van de redactionele copy renderde als mojibake. Fix: een tijdelijke SET NAMES latin1 op de brugverbinding, en daarna een one-pass conversie in het weekend. We draaiden eerst CONVERT TO CHARACTER SET utf8mb4 tegen een kopie, vergeleken het aantal bytes-gelijke rijen met bytes-gewijzigde, en wisselden pas in productie toen de diff ons niet meer verraste.
  3. Zoeken. De default search van WordPress raakt wp_posts. De site search gaf twee uur lang nul resultaten. Fix: een aparte WP_Query met een posts_clauses-filter dat de views targette, plus een kleine union zodat WordPress-native pagina's ook doorzoekbaar bleven.
  4. Stale OPcache. Apache serveerde de eerste elf minuten na cutover nog het legacy script omdat PHP's OPcache de oude index.php in geheugen hield. opcache_reset() via een tijdelijk admin-endpoint maakte het leeg. We hadden dat in het deploy script moeten zetten vóór de cutover, niet tijdens.

Totale downtime over het project van vier weken: 31 minuten, voor de database charset-conversie. De legacy URL-structuur overleefde intact. Search Console rapporteerde 4% meer klikken over de volgende 60 dagen, wat we toeschrijven aan performancewinst die het legacy script niet kon leveren (Apache plus object cache in plaats van een ongecachte single-file PHP CMS).

Wat we anders zouden doen

Vooral drie dingen.

Ten eerste onderschatten we de redactionele training. Het team kreeg WordPress, maar door de custom save_post-hooks verschenen sommige velden op onbekende plekken. We hadden een dag mee moeten lopen met een redacteur voordat we ook maar één regel code schreven. De Loom om 23:41 was het idee van een druk team van "we hebben een plan". Dat was niet genoeg discovery.

Ten tweede leefden de SQL-views uiteindelijk in een aparte repo. Dat was een fout. De views zijn nu onderdeel van de applicatie. Ze horen thuis in dezelfde repository als de mu-plugin, in een db/views/*.sql-directory, met een kleine runner die ze bij de deploy toepast. Inmiddels hebben we ze verhuisd.

Ten derde hadden we vóór de cutover een end-to-end test voor de brug moeten schrijven, niet erna. De mu-plugin had unittests rond de filterlogica, maar geen integratietest die "pagina aanmaken in WP-admin, schrijven via de brug, renderen via legacy URL, matchen tegen verwachte HTML" draaide. De mojibake-bug had zichzelf in CI gevangen als zo'n test had bestaan. Hem na het incident bouwen was twee keer het werk en de helft van de voldoening.

Toen we Pier bouwden liepen we tegen precies deze vorm van probleem aan. Hoe we het uiteindelijk oplosten was door de MySQL editor via SSH met de live database te laten praten en een version history bij te houden van elke view en stored procedure, zodat een recursieve CTE bewerken in productie de tweede keer niet meer eng is.

Het kleinste wat je vandaag kunt doen: pak één legacy tabel die je in vijf jaar niet hebt aangeraakt, schrijf de recursieve CTE die zijn parent chain afloopt, en sla de output op als view. Je ontdekt in zo'n twintig minuten waar je URL-structuur eigenlijk uit bestaat.

— Vragen —

Waarom de legacy content niet direct migreren naar wp_posts?

De URL-structuur was dragend en de redactie had specifieke gewoontes rond de legacy save-flow. Een dunne shell behoudt beide en laat je leveren in weken in plaats van maanden.

Houdt dit niet twee databases over om te onderhouden?

Eén database, met twee schema's erin. De legacy tabellen blijven de bron van waarheid. WordPress schrijft zijn eigen admin-metadata (sessions, users, options) maar dupliceert nooit de redactionele content.

Welke MySQL-versie heb je nodig voor de SQL-brug?

MySQL 8.0 of nieuwer voor de recursieve CTE die URL-paden materialiseert. Op 5.7 kun je een stored procedure inzetten die de chain iteratief afloopt, maar de views worden dan lastiger te doorgronden.