WordPress
Van WordPress naar Hydrogen: de ACF Repeater-bom van 12 dagen
Het dealerportaal van een Venlose bandengroothandel moest op dag zes live. We gingen live op dag achttien. Dit is het ACF Repeater-veld dat het gat opvulde.

Dag twaalf. Venlo, 23:40. De operations manager van een Venlose vrachtwagenbandengroothandel met vijfentwintig man zit in een videocall, deelt zijn scherm en ververst het dealerportaal dat we vorige vrijdag hadden moeten opleveren. Hij typt 295/80 R22.5 in, een Goodyear Marathon LHT, en de dealer-staffelprijs zou €387 moeten tonen. Hij toont €0,00. Hij probeert een andere maat. €0,00. Hij probeert de prijs voor een bulkorder van veertig banden op Michelin X Multi D. €0,00. Drie van de vier staffelprijzen in het nieuwe systeem zijn fout, het systeem gaat over tweeënzeventig uur live voor 340 dealers, en we zijn twaalf dagen onderweg in een WordPress-naar-Hydrogen-migratie die we op zes hadden ingeschat.
Dit is het verhaal van een ACF Repeater-veld uit 2017 dat twaalf dagen uit onze migratiesprint vrat, en de audit-stap die we sindsdien op elke verouderde migratie draaien.
De stack die we aantroffen
De klant draaide zijn dealerportaal op WordPress 4.9, WooCommerce 3.5 en PHP 7.0. Gehost bij een Nederlandse VPS-boer, in leven gehouden door een freelancer die in 2019 naar Berlijn was verhuisd en in 2021 stopte met e-mailen. Zo'n 1.200 vrachtwagenband-SKU's. Elke SKU droeg een Staffel, een prijsstaffel-blok, dat per maat de DOT-code, de EU-bandenlabel-data (rolweerstand, natte grip, geluid) en drie of vier prijsstaffels bevatte, afhankelijk van het ordervolume. Totaal: ongeveer 14.800 individuele staffels.
De prijsstaffels waren het product. Zonder die staffels is het portaal een brochure.
De nieuwe doelstack: Shopify Hydrogen, een storefront-framework op basis van Remix, met de catalogus en prijsstaffels opgeslagen als Shopify Metaobjects. Hydrogen voor de dealer-UI, Shopify Admin voor het team van de groothandel, Metaobjects voor alles wat WooCommerce met varianten had proberen te modelleren.
De eerste duizend producten gingen prima door
We bouwden de importer op de voor de hand liggende manier. Loop elke WooCommerce-post af, trek elke meta-row uit wp_postmeta, jaag ze door een normaliser en push ze naar de Shopify Admin GraphQL API als Metaobject-entries gekoppeld aan productvarianten. We testten op een sample van vijftig SKU's. Groen. We testten op tweehonderd. Groen. We draaiden de volle 1.200 en keken de logs voorbij scrollen. Geen errors. Shopify accepteerde alles. Validatie passte.
De volgende ochtend bouwde onze front-end engineer de Hydrogen prijs-formatter in, opende een productpagina en zag €0,00 op de plek waar een prijsstaffel hoorde te staan. We dachten aan een bug in de formatter. De formatter was prima in orde.
Wat ACF Repeater écht opslaat
Heb je het Repeater-veld van ACF Pro nooit direct in de database aangeraakt, dan is dit het stuk dat je wil weten. ACF geeft je geen nette rij per repeater-entry. Het geeft je:
- Eén rij met het aantal repeater-items (bv.
staffelsop6) - Eén rij per sub-field per index, met keys als
staffels_0_dot_code,staffels_0_eu_label_noise,staffels_1_dot_code, helemaal totstaffels_5_…
Dat is prima. Zo werkt de plugin sinds dag één. Onze importer ging er correct mee om. We pluisden de geïndexeerde keys uit, groepeerden per repeater en spuugden schone Metaobject-entries uit.
Wat onze importer niet had zien aankomen, was de tweede set meta-rows.
De bom die de freelancer in 2017 plaatste
Verstopt in de functions.php van het thema zat een helper die de oorspronkelijke freelancer had geschreven, zodat het interne team een platte snapshot van elke Staffel kon exporteren voor het Excel-bestand van hun accountant. De helper riep get_field('staffels', $post_id) aan, pakte de resolved nested array en zette die terug in wp_postmeta onder één key: _staffels_snapshot. Hij draaide bij elke productopslag, negen jaar lang. Versimpeld:
add_action('save_post_product', function ($post_id) {
$staffels = get_field('staffels', $post_id);
if (!empty($staffels)) {
update_post_meta($post_id, '_staffels_snapshot', $staffels);
}
});
De update_post_meta() van WordPress jaagt elke niet-scalaire waarde stil door PHP's serialize() voor het in de database belandt. Een developer die die regel in 2017 leest, ziet "sla die array op". De database ziet een string die begint met a:6:{, die geen SQL-tool parseert, geen andere plugin respecteert, en waar negen jaar lang geen LIKE 'a:%'-query naar zou worden geschreven.
Elk product droeg dus twee representaties van dezelfde data: de geïndexeerde ACF-rijen en een geserialiseerde PHP-array snapshot. De snapshot werd op runtime nooit door de live site uitgelezen. Het was een slapende write-only kolom. De accountant had de export al in 2019 niet meer gebruikt.
Onze importer las elke meta key op elk product uit. Hij wist niet dat de snapshot-key een back-up was. Wat onze importer betrof was _staffels_snapshot gewoon nóg een veld dat een plek in Shopify moest krijgen.
De normaliser slikte 'm in als getal
PHP's serialize() spuugt output uit die er zo uitziet:
a:6:{i:0;a:4:{s:8:"dot_code";s:4:"2419";s:5:"price";d:387.5;…
Die voorloop a:6: betekent "array, zes elementen". Onze normaliser had een guard die in feite vroeg: "ziet dit eruit als een getal, behandel het dan als prijs." De check was parseFloat(value) gevolgd door een isNaN-test. parseFloat("a:6:{…}") geeft NaN terug, en dat was prima geweest. Maar de data was eerst door een opschoonstap heen gegaan die een klein setje non-ASCII en low-ASCII prefixes wegtrok, restjes uit een CSV-round-trip uit 2018, en de voorloop a: was eraf gevreten. Wat overbleef was 6:{i:0;a:4:{…. parseFloat("6:{i:0;a:4:{…") geeft 6 terug.
De normaliser gaf 6 door aan de Metaobject-importer als de staffelwaarde van de variant. Shopify accepteerde dat. De échte staffel-rijen, een milliseconde eerder door dezelfde importer weggeschreven, werden overschreven door de snapshot-key, die alfabetisch later binnenkwam. 14.800 staffels werden gemiddeld eencijferige getallen tussen nul en twintig.
De prijs-formatter van Hydrogen rondde de meeste daarvan vervolgens af op €0,00, omdat de afgekapte snapshot-keys met 0: begonnen of unserialiseerden naar een leeg eerste element. Een handjevol producten liet €6,00 of €4,00 zien, en alleen daarom viel het patroon ons überhaupt op.
Bij elke migratie van een verouderde WordPress-installatie met ACF Pro: grep wp_postmeta op geserialiseerde payloads vóór je een regel importer schrijft. De query is SELECT meta_key, COUNT(*) FROM wp_postmeta WHERE meta_value LIKE 'a:%' GROUP BY meta_key;. Komt er ook maar één rij terug, dan zit er verstopte geserialiseerde data in en heeft je importer een expliciete unserialize-stap nodig.
Hoe we het vonden
Twaalf dagen erin, nadat we de Hydrogen prijs-component twee keer hadden herschreven, valuta-configuraties hadden gewisseld, debug-logs aan de Metaobject-importer hadden toegevoegd, en de hele import op een zaterdag hadden teruggedraaid en opnieuw uitgevoerd, draaide onze backend engineer één query tegen de bron-WordPress-database:
SELECT meta_key, LEFT(meta_value, 40) AS preview
FROM wp_postmeta
WHERE meta_value LIKE 'a:%'
LIMIT 20;
De eerste rij kwam terug: _staffels_snapshot | a:6:{i:0;a:4:{s:8:"dot_code"…. Hij plempte één woord in het teamkanaal: oh.
De fix
Op dag veertien gingen drie wijzigingen live.
Eerst voegden we een expliciete allowlist toe aan de importer. Elke meta key die werd ingelezen moest op een gedocumenteerde lijst staan, aangeleverd door het productteam van de klant, niet uit SELECT DISTINCT meta_key. De snapshot-key stond niet op de lijst en werd overgeslagen.
Daarnaast bouwden we een unserialize-stap voor elke meta-waarde die op /^a:\d+:\{/ matchte, die we naar een aparte "ACF snapshot"-handler stuurden. Die vergeleek de snapshot met de gestructureerde ACF-rijen voor dat product en logde een mismatch zodra ze van elkaar afweken. Dat gebeurde nooit, maar we wilden de audit trail.
En de normaliser zet geen waardes meer om naar getallen zonder een expliciet field-type contract. Een meta-waarde is alleen een prijs als de velddefinitie zegt dat het een prijs is. parseFloat krijgt niet langer het voordeel van de twijfel om een prijs te herkennen. In code ziet de nieuwe normaliser er ongeveer zo uit:
type FieldContract =
| { kind: 'price'; currency: 'EUR' }
| { kind: 'string'; maxLength: number }
| { kind: 'enum'; values: readonly string[] }
| { kind: 'snapshot'; ignore: true };
function decodeMeta(key: string, raw: string, contract: FieldContract) {
if (contract.kind === 'snapshot') return null;
if (/^a:\d+:\{/.test(raw)) {
throw new Error(`unexpected PHP serialized value at ${key}`);
}
if (contract.kind === 'price') {
const n = Number(raw.replace(',', '.'));
if (!Number.isFinite(n)) {
throw new Error(`expected price at ${key}, got ${raw.slice(0, 40)}`);
}
return n;
}
// string and enum paths follow the same pattern: contract dictates parser
return raw;
}
Elke key in het importer-manifest heeft nu een kind. Nieuwe keys komen de pipeline niet in zonder. Dat we throwen op onverwachte geserialiseerde waardes betekent dat als een toekomstige plugin het snapshot-patroon herintroduceert, de volgende import luid faalt in plaats van stil prijzen te overschrijven.
We draaiden de import opnieuw. De 14.800 staffels landden correct. Dag achttien ging het portaal live. De operations manager appte ons om 06:12 een screenshot van zijn dealer-prijspagina met het bericht: klopt nu.
We lieten beide systemen nog twee weken naast elkaar draaien. Elke nachtjob dumpte een diff tussen de WooCommerce-database en de Shopify-catalogus. De eerste drie nachten leverden elf kleine verschillen op, vooral trailing whitespace in DOT-codes, die de oorspronkelijke migratie netjes had meegenomen. Geen daarvan raakte de prijzen. Op nacht tien was de diff leeg en zette het interne team de oude VPS uit.
Wat dit ons leerde over verouderde WordPress-migraties
De fout zat niet in de helper van de freelancer. Dat was, in context, in 2017, redelijke code. De fout zat in het inlezen van elke meta key op elk product zonder te vragen waar elke key voor diende. wp_postmeta is een open dumpplaats. Negen jaar lang schrijven plugins, themes en eenmalige helpers erin. In 2026 heeft elke productie-WordPress-database die we auditen een fors deel meta-rijen die niets op de live site ooit uitleest. We hebben sites gezien waar dat de meerderheid is.
De snelste audit-stap die we kennen is de grep op geserialiseerde payloads hierboven. De op één na snelste: vraag het interne team om een lijst meta keys waar ze daadwerkelijk om geven en behandel al het andere als verdacht.
Een inventarisatie-query die negentig seconden kost
Voordat we ook maar één regel importcode schrijven, draaien we nu één query tegen de bron-database en lezen het resultaat van boven naar beneden:
SELECT
meta_key,
COUNT(*) AS rows,
ROUND(AVG(LENGTH(meta_value))) AS avg_bytes,
MAX(LENGTH(meta_value)) AS max_bytes,
SUM(meta_value LIKE 'a:%') AS serialized_rows
FROM wp_postmeta
GROUP BY meta_key
ORDER BY rows DESC
LIMIT 50;
Op de Venlose installatie kwamen daar 312 unieke keys uit. De top twintig was goed voor 88% van de rijen. Drie van die top twintig, waaronder _staffels_snapshot, waren write-only: niets in het actieve thema of een geïnstalleerde plugin las ze ooit uit. We bevestigden dat door per key door de codebase te grepen. De andere verdachten bleken een legacy Yoast SEO-cache van een in 2020 verwijderde plugin en een halfafgemaakt CSV-import staging-veld uit 2019. Beide konden eruit zonder de live site te raken.
De recente Hacker News-discussie over het bouwen van betrouwbare agentic systemen kwam uit op een vergelijkbaar principe voor AI-pipelines: vertrouw de input-vorm niet, begrens 'm. Een migratie-importer is een ander soort agent, maar de regel gaat op. Valideer op de grens. Weiger als default. Maak het veilige pad expliciet.
De smoke test die de volgende ving
Na dit project hebben we een stap toegevoegd aan ons standaard-playbook. Na elke import trekt de migratie-runner vijftig willekeurige SKU's end-to-end: Shopify Admin, Hydrogen storefront, dealerportaal-render. Hij asserteert dat de staffel die in de browser verschijnt gelijk is aan de staffel in de bron-WordPress-database. Niet de bron-CSV. Niet de bron-GraphQL-respons. De browser, tegen de originele DB.
Twee weken nadat het Venlose portaal live ging, ving die test een tweede ACF Repeater op een ander veld (banden_certificeringen), waar dezelfde freelancer hetzelfde snapshot-patroon had geschreven. De fix kostte een uur omdat we de unserialize-handler al hadden.
Toen we dat dealerportaal voor de bandengroothandel herbouwden, zat de echte winst niet in Hydrogen of Remix. Hij zat in de audit-stap. We hebben de grep op geserialiseerde payloads en de end-to-end smoke test toegevoegd aan elke legacy migratie die we offreren. Heeft je stack een wp_postmeta-tabel die ouder is dan drie jaar, draai die grep dan vanavond nog, vóór je iets anders doet.
Kern
Vóór je ook maar één regel WordPress importer-code schrijft: grep wp_postmeta op geserialiseerde arrays. De bom in je migratie is bijna altijd een write-only key die niemand zich nog herinnert.
FAQ
Waarom slaat ACF Pro Repeater-data als losse meta-rijen op?
ACF plat de repeater bij het opslaan: één rij bevat het aantal items, en elk sub-field op elke index krijgt zijn eigen rij in wp_postmeta. Zo werkt het sinds dag één, en daarom moeten migratie-importers de keys op index groeperen.
Hoe spoor ik geserialiseerde PHP-arrays op in een WordPress-database?
Draai SELECT meta_key, COUNT(*) FROM wp_postmeta WHERE meta_value LIKE 'a:%' GROUP BY meta_key tegen de bron-DB. Elke rij die terugkomt is een geserialiseerde payload die je importer expliciet moet afhandelen.
Is dit probleem specifiek voor Shopify Hydrogen?
Nee. Dezelfde misinterpretatie gebeurt bij elke importer die wp_postmeta als een platte key-value store behandelt. Hydrogen maakte het alleen zichtbaar omdat de prijs-formatter de kapotte waardes op het scherm naar €0,00 afrondde.
Hoe lang duurt een typische WooCommerce-naar-Shopify Hydrogen-migratie?
Voor een catalogus van 1.000 tot 2.000 SKU's met custom fields: reken op drie tot zes weken engineering vóór de launch, plus twee weken parallel draaien zodat het interne team prijzen en rapportages kan controleren.
Moeten we ACF Pro op de nieuwe stack in leven houden of het veldmodel herbouwen?
Herbouwen. De Repeater-vorm werkt omdat WordPress permissive is; in een getypeerde Metaobject-wereld wil je expliciete velddefinities, validatie op de grens, en geen slapende write-only kolommen uit oude export-helpers.