WordPress
WordPress-migratie war story: dubbel-escaped OE-array
Dag negen van een Remix-migratie. Het dealerportaal werkt, de catalogus laadt, maar 380 garagebedrijven zien de verkeerde kortingsstaffel. De data is technisch gezien aanwezig.

Dag negen van de dealerportaal-migratie
De groothandel zit op een bedrijventerrein ten oosten van Arnhem. Eenentwintig man, veertien miljoen euro aan auto-onderdelen die jaarlijks door het magazijn gaan, een dealerportaal waar 380 garagebedrijven inloggen als ze donderdagochtend een remschijf nodig hebben. Het portaal draaide sinds 2018 op WordPress 4.9, WooCommerce 3.5 en een berg custom PHP 7.2. We zaten negen dagen in een port naar Remix en Postgres.
Met "negen dagen in" bedoel ik: we zaten vast. De catalogus renderde. Login werkte. SKU's klopten. Maar de kortingsstaffels per merk — de kortingsstaffel waar elke garage op vaart om te weten of een Bosch dynamo €212 of €189 kost — klopten stilletjes niet. Niet voor iedereen. Voor 380 van de 412 dealer-accounts.
De query in het nieuwe systeem klopte. De Postgres-rijen die hij teruggaf klopten. Het probleem zat stroomopwaarts van ons, in de oude database, in één kolom die we negen dagen lang als evangelie hadden gelezen.
Acht van die negen dagen waren we ervan overtuigd dat de bug van ons was. We lazen de Remix-loader twee keer door. We draaiden de kortingsstaffel-functie opnieuw tegen een fixture van twaalf bekend-goede dealers — geslaagd. We dumpten de Postgres-rijen voor een van de getroffen garages en vergeleken ze byte voor byte met de rijen van een werkende dealer; identiek. We bouwden zelfs de cache-laag from scratch opnieuw op, in de hoop dat een verlopen Redis-key de lookup vergiftigde. Niets ervan raakte het probleem aan, want het probleem zat drie lagen stroomopwaarts van alles wat wij hadden geschreven.
Wat er werkelijk in wp_postmeta stond
Elk van de 14.200 onderdelen droeg een meta_key genaamd _oe_numbers. Original Equipment-nummers — de fabrikantencodes waarop een garage zoekt als hij het BMW-equivalent van een generiek onderdeel wil. De PHP die de kortingsstaffel voedde, liep die array door, matchte de merkprefix en zocht vervolgens de kortingstier op voor de garage.
Zo zag één rij eruit, recht uit phpMyAdmin gekopieerd:
a:3:{i:0;s:11:\"BMW-1234567\";i:1;s:11:\"VAG-8901234\";i:2;s:11:\"MB-A0005678\";}
Kijk naar de backslashes. Die horen daar niet. Een correct geserialiseerde PHP-array schrijft zijn strings met kale dubbele aanhalingstekens:
a:3:{i:0;s:11:"BMW-1234567";i:1;s:11:"VAG-8901234";i:2;s:11:"MB-A0005678";}
De blob in productie was dubbel ge-escaped. Eén keer toen een import-plugin in 2018 hem via een $wpdb->prepare()-aanroep wegschreef naar de database — die zelf al escapet. En een tweede keer toen iemand — vermoedelijk een phpMyAdmin-export uit een vergeten back-up — de kolom door nog een escape-laag heen haalde. Het resultaat: elke " werd \", de bytecounts in de s:11:-prefixen waren nu leugens, en PHP's unserialize() gaf stilletjes false terug.
Waarom unserialize stil false teruggaf
PHP's serialisatieformat telt bytes. s:11:"BMW-1234567" betekent "string, elf bytes, dan BMW-1234567". Verander je de payload in \"BMW-1234567\", dan ziet de parser opeens dertien bytes tussen de aanhalingstekens, geen elf. De length header liegt. unserialize geeft je false terug plus een notice die je alleen ziet als je error logging op E_NOTICE staat.
WooCommerce 3.5 slikte die false. get_post_meta() op een dubbel ge-escapete blob gaf een lege array terug, de kortingsstaffel-code viel terug op "geen OE-match, terug naar basisprijs", en 380 garages hadden jarenlang de verkeerde tier gekregen. Stilletjes. Niemand had geklaagd, want de prijzen zaten dicht genoeg in de buurt dat de garages aannamen dat de staffel was bijgewerkt.
De fallback was één regel in een helper-functie die we uiteindelijk uit de theme grepten:
$oe = get_post_meta($product_id, '_oe_numbers', true) ?: [];
$tier = $this->match_brand_prefix($oe, $dealer) ?? 'base';
?: [] en ?? 'base'. Twee null-coalescing vangnetten die samen een lege OE-array niet meer te onderscheiden maakten van een ontbrekende merk-match. We hadden hetzelfde patroon overgenomen in onze Remix-code en het tijdens de rewrite gestript, omdat we expliciete errors wilden bij ontbrekende OE-arrays. Die strengheid leverde ons de bug op die de oude code verborgen had gehouden.
De dader uit 2018
We trokken de plugin-historie van de site en vonden een CSV-importer geïnstalleerd op 14 maart 2018. Hij las exports uit het oude ERP van de groothandel en schreef ze in wp_postmeta. Hij serialiseerde de array in PHP en gaf de geserialiseerde string door aan $wpdb->insert().
Op zich prima. $wpdb escapet correct. De bug was dat de plugin addslashes() aanriep op de al geserialiseerde string voor hij hem aan $wpdb gaf, omdat de oorspronkelijke auteur ooit door een ungeescapete insert was gebeten en er een defensieve laag bovenop had cargo-culted. $wpdb voegde daar zijn eigen escaping aan toe. De dubbele escape zat ingebakken in elke rij die de importer schreef tussen 2018 en 2021, toen de groothandel stopte met de plugin en onderdelen handmatig ging uploaden.
14.200 rijen. Ongeveer 11.800 geschreven door de importer, de rest met de hand. De handmatige rijen waren prima. Daarom hadden 32 dealers de correcte kortingsstaffel en 380 niet.
Waarom wp-cli search-replace het niet ving
wp search-replace begrijpt PHP-serialisatie. Het loopt door een geserialiseerde blob, vindt de strings erin en werkt de bytecounts bij bij een vervanging. Wat het niet doet, is je vertellen dat de blob al kapot is voordat het hem aanraakt. We hadden search-replace tijdens de migratie vier keer gedraaid om een staging-domein te wisselen. Elke run slaagde zonder waarschuwingen, want de parser binnenin wp-cli kon de blob überhaupt niet openen — hij brak af en liet de bytes met rust. Geen error, geen rij-aantal-delta in de samenvatting, niets.
We probeerden ook het Search Replace DB-script dat het vorige bureau in de repo had achtergelaten, plus de Better Search Replace-plugin uit de admin. Bij beide dezelfde blinde vlek. Ze parsen, ze breken af, ze zeggen niets. De phpMyAdmin SQL-preview renderde de bytes als tekst zonder ze te parsen, dus op het eerste gezicht waren de kapotte rijen niet te onderscheiden van de schone. Elke standaardtool die we erbij pakten zat stroomafwaarts van een werkende unserialize(), en unserialize() faalde sinds maart 2018 in stilte.
Zo verbrandden we negen dagen. De data zag er goed uit. De migratielog was schoon. De catalogus renderde. Het eerste symptoom was een telefoontje op dag acht van een garage in Ede, met de vraag waarom zijn Bosch-onderdelen stilletjes elf procent duurder waren geworden.
Hij heette Marco. Hij runde een garage met drie hefbruggen die misschien veertig Bosch-dynamo's per kwartaal verkocht, en hij hield zelf een spreadsheet bij met de meest bestelde SKU's en de tier waarop hij hoorde te zitten. De accountmanager van de groothandel zette het telefoontje om 16:40 op een vrijdag door naar ons. Maandagochtend trokken we de broncode van de importer uit de cold storage.
De reparatie, in drie passes
Toen we eenmaal wisten waar we naar keken, was de fix mechanisch. We schreven een eenmalig PHP CLI-script dat direct werkte tegen een staging-kopie van de oude MySQL-database die we mochten weggooien. Drie passes.
Pass één: detecteer elke rij waarvan de meta_value een dubbel ge-escapete geserialiseerde blob was. De signature is een leidende a:N:{ gevolgd door s:N:\" in plaats van s:N:". MySQL's LIKE behandelt backslash als zijn eigen escape character, dus het patroon heeft vier backslashes nodig om er één letterlijk te matchen:
SELECT meta_id, post_id, meta_value
FROM wp_postmeta
WHERE meta_key = '_oe_numbers'
AND meta_value LIKE 'a:%'
AND meta_value LIKE '%s:%:\\\\"%';
Dat leverde 11.842 rijen op. Dicht bij onze schatting.
Pass twee: voor elke rij, strip de escaping en serialiseer opnieuw. We probeerden de blob niet in-place op te schonen met een regex op de bytecounts — te makkelijk om een edge case te missen waarin het OE-nummer zelf een aanhalingsteken bevatte. In plaats daarvan gebruikten we stripslashes() om één laag te verwijderen, voerden het resultaat terug door unserialize() en serialiseerden de resulterende array schoon opnieuw.
<?php
require __DIR__ . '/wp-load.php';
global $wpdb;
$rows = $wpdb->get_results(
"SELECT meta_id, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key = '_oe_numbers'
AND meta_value LIKE 'a:%'"
);
$fixed = 0;
$skipped = [];
foreach ($rows as $row) {
$stripped = stripslashes($row->meta_value);
$decoded = @unserialize($stripped);
if (!is_array($decoded)) {
$skipped[] = $row->meta_id;
continue;
}
$clean = serialize(array_values($decoded));
$wpdb->update(
$wpdb->postmeta,
['meta_value' => $clean],
['meta_id' => $row->meta_id]
);
$fixed++;
}
printf("Repaired %d rows. Skipped %d.\n", $fixed, count($skipped));
file_put_contents(__DIR__ . '/skipped.txt', implode("\n", $skipped));
Pass drie: handmatige review van de overgeslagen rijen. Zevenendertig rijen waren twee keer dubbel ge-escaped — een triple-escape — omdat ze ook waren aangeraakt door een migratiescript uit 2019 dat het vorige bureau had gedraaid en vervolgens vergeten te loggen. Eén stripslashes() was niet genoeg. We draaiden het script opnieuw op die 37 rijen met een tweede pass, en allemaal kwamen ze schoon terug.
De kortingsstaffel weer in elkaar zetten
Met wp_postmeta op orde draaiden we de export-naar-Postgres-pipeline opnieuw. De onderdelencatalogus had nu de correcte OE-arrays. De kortingsstaffel-logica in Remix kon een merkprefix matchen aan een kortingstier zonder tegen een lege array aan te lopen.
Daarna deden we het ding dat we op dag één hadden moeten doen: een volledige reconciliatie tegen het ERP van de groothandel. Van de 380 getroffen garages eindigden er 376 met prijzen die exact matchten met het ERP. Vier hadden maatwerkkortingsafspraken die door het salesteam in een Google Sheet waren opgeslagen, nooit in het portaal. Die vier trokken we in een JSON-bestand en laadden we op dag elf.
Op dag twaalf zetten we het nieuwe portaal live achter een feature flag, eerst tien procent van het verkeer, en draaiden we een parallelle pricing-job die elke offerte ieder uur tegen het ERP vergeleek. De reconciliatie bracht in de twee weken erna nog twee maatwerkkortingsafspraken aan het licht, allebei door het salesteam ook in privé-spreadsheets bewaard. We voegden een bespoke_override-kolom toe aan het Postgres-schema en koppelden er het CRM van het salesteam aan, zodat de volgende keer dat iemand bij een kop koffie een kortingsstaffel afsprak, die op één plek terechtkwam.
Een WordPress-migratie is nooit alleen een migratie. Het is de eerste keer in jaren dat iemand de data echt streng leest. Behandel de oude database als getuige, niet als bron van waarheid.
Wat we nu op dag één doen
Elke WordPress-migratie die we nu oppakken, begint met één commando voor we een regel nieuwe code aanraken. We dumpen elke distincte meta_key die geserialiseerde data bevat, pikken een voorbeeldrij per key en proberen er unserialize() op. Geeft één key consistent false terug, dan stoppen we en gaan we auditen. Lukt de audit niet in een dag, dan vertellen we de klant dat de migratie langer gaat duren en waarom — voordat ze een launch-datum hebben geboekt en hun dealers hebben ingelicht.
<?php
// audit-meta.php — run with: wp eval-file audit-meta.php
global $wpdb;
$keys = $wpdb->get_col(
"SELECT DISTINCT meta_key
FROM {$wpdb->postmeta}
WHERE meta_value LIKE 'a:%'
OR meta_value LIKE 'O:%'"
);
$bad = [];
foreach ($keys as $key) {
$sample = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta}
WHERE meta_key = %s LIMIT 1",
$key
));
if (@unserialize($sample) === false && $sample !== 'b:0;') {
$bad[] = $key;
}
}
print_r($bad);
De audit gaat verder dan geserialiseerde strings. We checken ook: elke meta_key waarvan de waarden met O: beginnen (geserialiseerde objecten — veel erger, omdat de class-definitie misschien niet meer bestaat op de nieuwe stack). Elke optie in wp_options groter dan 1MB (vrijwel altijd een uit de hand gelopen autoload-waarde). Elke cron job die in 180 dagen niet gevuurd heeft. Elke gebruiker met een rol die niet in wp_user_roles staat. Geen ervan heeft iets met de rewrite te maken. Allemaal kosten ze je een dag als je ze op dag acht in plaats van dag één vindt.
Twintig regels PHP. Het had ons negen dagen gescheeld.
Toen we dit portaal herbouwden voor de Arnhemse groothandel, was het ding waar we tegenaan liepen precies die stille, structurele leugen in de oude database — en de oude PHP had die een half decennium lang beleefd genegeerd. Uiteindelijk losten we het op door de migratie eerst als forensisch onderzoek te behandelen en pas daarna als bouwklus, het draaiboek dat nu elke legacy migratie van ons ankert.
Sta je op het punt WordPress te verlaten, draai die unserialize()-sweep dan vanmiddag nog. Binnen een uur weet je of er een gat van negen dagen in je migratie zit.
Kern
Een WordPress-migratie is de eerste keer in jaren dat iemand je oude data echt streng leest. Audit je geserialiseerde meta voor je een regel nieuwe code schrijft.
FAQ
Waarom geeft PHP's unserialize() false terug bij dubbel ge-escapete data?
PHP serialiseert strings met een bytecount-prefix zoals s:11:"BMW-1234567". Komen er backslashes bij, dan groeit de daadwerkelijke bytecount, maar de prefix niet. PHP detecteert de mismatch en breekt af met false.
Vangt wp-cli search-replace een dubbel ge-escapete geserialiseerde blob?
Nee. Het probeert de blob eerst te parsen. Lukt het parsen niet, dan laat het de rij ongemoeid en meldt het niets — geen waarschuwing, geen rij-aantal-delta. Je moet de corruptie met je eigen sweep detecteren.
Hoe vind je dubbel ge-escapete meta in een WordPress-database?
Selecteer elke distincte meta_key waarvan de waarden met a: of O: beginnen, neem één voorbeeldrij per key en draai er unserialize() op. Elk sample dat false teruggeeft op een waarde die geserialiseerd lijkt, is een reparatiekandidaat.
Kun je dubbel ge-escapete geserialiseerde data met een regex repareren?
Riskant. Bevatten de strings binnenin aanhalingstekens, dan gaat de bytecount-herschrijving mis. De veiligere route is stripslashes() om één escape-laag eraf te pellen, unserialize() om te valideren, en daarna weer serialize() om een schone blob te schrijven.