WordPress
Headless WordPress-migratie: 13 dagen vast op ACF Pro
Een logistiek softwarebedrijf in Mechelen met 31 mensen liep dertien dagen achter op een headless WordPress-launch. De blocker: vier bytes, a:0:{} op 8.600 posts.

Dag dertien op kantoor in Mechelen
Op een vrijdag begin juni, om 23:18, stuurde de lead engineer van een logistiek softwarebedrijf in Mechelen (31 mensen) één bericht in ons projectkanaal:
"Het is nog steeds a:0:{} op elke post. Stuk voor stuk. Ik ga naar huis."
De launch was al twee keer verschoven. De nieuwe site moest op een dinsdag live. We zaten dertien kalenderdagen achter, en de hele blocker was vier bytes.
Deze post is de autopsie van die dertien dagen. Draai je een WordPress-site die al langer dan vijf jaar staat en denk je aan headless? Lees dit voordat je begint.
De headless stack
De klant draaide sinds 2017 een WordPress 6.4-site met bedrijfsinfo en cases. Page builder op page builder, met ACF Pro er bovenop geplakt zodat marketing tien cases per maand kon publiceren zonder een ticket aan te maken. Engineering kwam niet aan WordPress, behalve als er iets stuk was.
De opdracht voor de rebuild was niet bijzonder voor 2026. WordPress blijft de editor en content store. Next.js rendert de publieke site. WPGraphQL voedt Next.js tijdens de build, met on-demand revalidation voor de rest. Fly.io voor de front-end, Hetzner voor de WordPress origin achter Cloudflare.
Er stonden 8.604 case-posts in de database. Elke post gebruikte één ACF Pro flexible-content veld genaamd modules, met zeven mogelijke layouts: hero, quote, two-column, gallery, KPI-grid, video embed, CTA. Ongeveer 84% van de cases gebruikte vier of meer module-instances. Het flexible-content veld wás de site.
De eerste WPGraphQL-response
De eerste query op staging vroeg één case op via slug, met de modules flexible-content union:
query {
caseStudy(id: "fonteyne-returns-2024", idType: SLUG) {
title
modules {
... on CaseStudyModulesHeroLayout { heading subheading }
... on CaseStudyModulesQuoteLayout { quote attribution }
}
}
}De response kwam terug met modules: null en geen GraphQL-fout. Schema mismatch, dachten we. We bevroegen het rauwe metaData-veld op de post direct. Daar stond het, in de JSON-response:
{ "key": "modules", "value": "a:0:{}" }Achtduizend zeshonderdvier posts. Allemaal a:0:{}. Elk flexible-content veld gaf dezelfde letterlijke string terug. De CMS-admin renderde de modules netjes. De front-end zag er geen één.
Wat we hebben uitgesloten
Dag een tot dag zes was konijnenhol-werk. Voor de volledigheid: dit was het níét.
De WPGraphQL-versie. We zetten hem op 1.27 vast, probeerden daarna 1.26 en de 2.0-beta. Zelfde response.
De versie van WPGraphQL for ACF. De plugin werd in 2023 herschreven en we wezen eerst die rewrite aan. Het was de rewrite niet.
De ACF Pro-versie. We testten 6.2.10 tegenover 6.3.4. Zelfde response.
De manier van field-group registreren. We probeerden zowel PHP-registratie als JSON-sync. Zelfde response.
Een Cloudflare-cachelaag voor de origin. We omzeilden hem. Zelfde response. Een WordPress object cache. We leegden hem, daarna zetten we hem uit. Zelfde response.
Op dag zes was het front-end team gestopt met de case-templates en bouwde het al het andere. Op dag negen vroeg de marketingdirecteur, bewonderenswaardig kalm, of we wisten wat er mis was. Nee. Dus gingen we naar de database.
De double-serialization val van ACF
ACF Pro slaat de layout-lijst van een flexible-content veld op als een geserialiseerd PHP-array in wp_postmeta. Voor ons modules-veld ziet een gezonde rij er zo uit:
meta_key: modules
meta_value: a:3:{i:0;s:4:"hero";i:1;s:5:"quote";i:2;s:7:"two_col";}De storage-laag van WordPress roept maybe_serialize() aan bij schrijven en maybe_unserialize() bij lezen. Als get_field() draait, komt de waarde terug als een echt PHP-array. WPGraphQL for ACF mapt elke layout vervolgens op een GraphQL union member. Dat is het happy path. De ACF flexible-content documentatie beschrijft het layout-contract en de WPGraphQL for ACF documentatie beschrijft hoe dat contract op GraphQL unions wordt gemapt.
Wat we in productie aantroffen, was anders. De rij zag er zo uit:
meta_key: modules
meta_value: s:8:"a:0:{}";Een string. Met de letterlijke tekst a:0:{} erin. Geserialiseerd.
Toen maybe_unserialize() over die waarde liep, gaf hij de binnenste string a:0:{} terug. Geen tweede pass. Dat hoort ook niet. De resolver van ACF kreeg een string waar hij een array verwachtte, behandelde hem als een lege layout-lijst, en de front-end union gaf niets terug. De vier bytes waren niet de oorzaak. Ze waren de echo.
De oorzaak was een migratiescript uit 2019, toen de site van host wisselde. Het script trok wp_postmeta uit de oude database, draaide er een serialize() overheen om de waardes "transportveilig te maken", en zette ze terug. Elk flexible-content veld dat op het moment van die verhuizing leeg was, was nu een geserialiseerde representatie van een geserialiseerd leeg array. Nieuwe posts van na 2019 waren prima. Posts die sinds 2019 bewerkt waren, waren ook prima, want het opslaan in de admin re-serialiseerde de meta-waarde via het juiste pad. De 8.517 cases die ooit waren gepubliceerd en daarna jaren met rust gelaten, waren allemaal double-serialized.
Is je WordPress-site meer dan eens tussen hosts verhuisd? Query dan wp_postmeta op waardes waarvan de opgeslagen string zélf een geserialiseerde string is. WordPress-functies vangen dit niet op. WPGraphQL ook niet. De admin-UI repareert het stilletjes op het moment dat iemand de post bewerkt, en daarom valt het jarenlang niemand op.
Het opschoonscript
De fix was één script, gedraaid binnen wp-cli. We deden drie dingen. Elke rij auditen. Het uitpakken eerst dry-run draaien en het resultaat loggen. De opgeschoonde waarde terugschrijven in een transactie met een rollback-pad.
De kern van de cleanup, zonder de dry-run flag voor de leesbaarheid:
<?php
// wp-cli eval-file fix-acf-double-serialize.php
global $wpdb;
$flex_keys = [ 'modules', 'page_blocks', 'kpi_grid_items' ];
foreach ( $flex_keys as $key ) {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT meta_id, post_id, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND meta_value LIKE 's:%%'",
$key
) );
foreach ( $rows as $row ) {
$first = @unserialize( $row->meta_value );
if ( ! is_string( $first ) ) { continue; }
$second = @unserialize( $first );
if ( ! is_array( $second ) ) { continue; }
$wpdb->update(
$wpdb->postmeta,
[ 'meta_value' => maybe_serialize( $second ) ],
[ 'meta_id' => $row->meta_id ]
);
WP_CLI::log( "Fixed post {$row->post_id} key {$key}" );
}
}We draaiden hem eerst tegen een kloon van productie. De audit vond 8.517 van de 8.604 cases aangetast, plus 3.212 posts over twee andere content types met flexible-content velden die marketing was vergeten. De fix was na 14 minuten klaar. We deden een diff op de GraphQL-output voor en na, op een sample van 200 posts. Elke layout kwam terug.
We draaiden hetzelfde script tegen productie tijdens een onderhoudsvenster op zondag. De launch ging maandag live.
Auditen vóór de rebuild
De meeste van die dertien dagen gingen op aan het uitsluiten van de verkeerde dingen. Het uur echte pijn was een database query. De les: draai die query op dag nul, vóór je één regel Next.js schrijft.
Drie checks die we nu draaien bij elk WordPress-naar-headless traject.
Een rauwe telling van wp_postmeta-rijen waarvan de meta_value een geserialiseerd string-fragment opslaat in plaats van een array of een scalair. Is de telling niet nul, dan zit er ergens een migratielitteken, en dat moet je weten vóór je er een resolver bovenop bouwt.
Een WPGraphQL-probe op de ACF-velden die er voor jou echt toe doen, tegen minstens vijftig willekeurig gekozen posts over de hele leeftijdsrange van de site. Sample de oudste 10%, de middelste 80% en de nieuwste 10%. De bug uit dit verhaal was onzichtbaar op de nieuwste 10%, omdat die posts ná de slechte migratie zijn aangemaakt. Precies zo overleefde hij zeven jaar losse QA.
Een diff van de field-group JSON tussen de live site en de staging-site. ACF JSON sync is betrouwbaar, totdat iemand een field group bewerkt via de admin op productie en vergeet het JSON-bestand te committen. Beide versies van die field group kunnen technisch correct zijn en alsnog naar verschillende layouts resolven op verschillende omgevingen.
Niks van dit werk is interessant. Alles bij elkaar had ons dertien dagen gescheeld.
Eén ding om maandag te checken
Toen we de headless front-end voor het bedrijf in Mechelen bouwden, was het veld dat de launch bijna sloopte sinds de hostmigratie van 2019 stilletjes een double-serialized leeg array. Wij vonden het op dag dertien in plaats van dag nul. Het meeste legacy migratiewerk dat we bij ABN doen begint nu met een database-audit van een half uur, vóór elk gesprek over architectuur. De prijs van zo'n litteken vinden op dag nul is een SQL query. Op dag dertien is het een launch.
Open vanavond wp-cli op een kloon van je productiedatabase en draai één query:
SELECT COUNT(*) FROM wp_postmeta
WHERE meta_value LIKE 's:%a:0:{}%';Is het antwoord niet nul, dan heb je hetzelfde litteken als wij. Beter ontdek je dat op een maandag dan om 23:18 op de tweede vrijdag van je rebuild.
Kern
Voordat je headless gaat op een legacy WordPress-site: query wp_postmeta op double-serialized waardes. De admin-UI verbergt het probleem; WPGraphQL niet.
FAQ
Wat betekent die letterlijke string 'a:0:{}' eigenlijk?
Het is de PHP-geserialiseerde weergave van een leeg array. WPGraphQL gaf hem als string terug omdat de onderliggende meta-waarde twee keer geserialiseerd was, dus na één unserialize-pass bleef er een string staan.
Laat de WordPress admin-UI dit probleem zien?
Nee. Eén keer een post bewerken en opslaan re-serialiseert de waarde correct via het juiste codepad. Het litteken blijft alleen zitten op posts die niet bewerkt zijn sinds de slechte migratie.
Kan ik het opschoonscript direct op een live site draaien?
Ja, maar spiegel de database eerst en draai de cleanup op die kloon. Vergelijk de audit-telling voor en na. Schrijf pas terug naar productie als de dry-run getallen kloppen met wat je verwachtte.
Gebeurt dit nog steeds met de nieuwste ACF Pro en WPGraphQL for ACF?
Ja. De bug zit in de opgeslagen data, niet in de plugins. Beide roepen unserialize één keer aan en vertrouwen het resultaat. Is je meta double-serialized, dan redt geen enkele plugin-update je.
Hoe zie ik of een meta-waarde double-serialized is?
Een geserialiseerde string begint met 's:' gevolgd door een lengte. Geeft unserialize daarop een andere string terug die zelf begint met 'a:', 'O:' of 's:', dan kijk je naar een double-serialized waarde die een tweede pass nodig heeft.