WordPress
Van WooCommerce naar Shopify: de Dokan-serialisatieval
De cutover van een Tilburgse textielgroothandel van WordPress naar Shopify lag tien dagen stil omdat WP All Export een Dokan-vendortabel bij rij 1.000 afkapte.

Het Slack-bericht kwam binnen op dinsdag om 09:14 in maart: "Waarom staan er 620 vendors op €0,00 voor februari?" De cutover hoorde stil te zijn verlopen. De nieuwe Shopify Plus-storefront was zes uur live. De eerste geautomatiseerde maandafrekening, het commissieoverzicht waar 1.840 vendors van een Tilburgse textielgroothandel hun inbox voor in de gaten houden, was om 03:00 CET afgevuurd. Een derde van die vendors had een PDF gekregen die in net Helvetica vertelde dat ze precies nul euro hadden verdiend.
We zaten tien dagen in de migratie. We dachten dat we klaar waren.
De site die we erfden
De groothandel — laten we ze de klant noemen — draaide sinds 2019 op WordPress 5.8 en WooCommerce 6. Ze waren bijna per ongeluk in een marketplace-vorm gerold: weverijen in Turkije, India en Portugal verkochten via hun domein, en Dokan regelde de vendor-split. Vijfentwintig mensen op het kantoor in Tilburg verwerkten retouren, vendor-disputen en het soort B2B-papierwerk dat EU-textieldistributie nu eenmaal vraagt.
De Dokan-installatie was aangepast. Het standaard commissiemodel in Dokan is een vast percentage. De klant had in plaats daarvan een staffelsysteem op volume gebouwd: een vendor die onder €5.000 per maand draaide betaalde 18%, €5.000 tot €20.000 betaalde 16%, en zo door over zes brackets. Die staffeltabel woonde nergens in de WooCommerce-admin en nergens in de Dokan-instellingen. Hij woonde in één rij per vendor in wp_usermeta, onder een meta key die de oorspronkelijke developer dokan_commission_tiers had genoemd, met een PHP-serialized array erin.
Dit is, voor de duidelijkheid, een volstrekt normaal patroon voor een WordPress-plugin. Het is ook iets dat je bijt op het moment dat je WordPress probeert te verlaten.
De export die er goed uitzag
We hadden WP All Export gekozen voor de vendor-tabel omdat die tool WooCommerce custom fields beter aankan dan de WP-CLI exporters, en omdat de ops lead van de klant de UI al kende. We draaiden een sample van 100 vendor-rijen naar CSV. We deden een diff van de ge-unserializede staffels tegen de live admin. Schone match. We draaiden 500. Schone match. We gaven groen licht voor de volledige export.
De volledige export schreef een CSV van 47 MB. Het aantal rijen klopte: 1.840. De headers klopten. Elke cel bevatte data. Elke cel. Dat is het stuk waar we het langst overheen keken.
Een PHP-serialized string die midden in de array is afgekapt bevat nog steeds tekst. Een steekproef in een spreadsheet vangt dat niet. Je moet elke rij unserialize()'en en asserten dat de return niet false is.
De truncatie zat op de chunk-grens van WP All Export. Standaard verwerkt de tool records in chunks van 1.000 rijen en schrijft elke chunk eerst naar een tijdelijke buffer voordat de CSV wordt weggeschreven. Bij vendors met serialized staffel-blobs die boven de per-field cap van die buffer uitkwamen, vielen de laatste bytes stil weg. We hadden vendors met zes staffel-brackets en per-staffel currency overrides — de long tail van de tabel — en daar viel het mes.
De CSV zag er voor zo'n rij zo uit:
user_id,dokan_commission_tiers
4421,"a:6:{i:0;a:3:{s:6:""volume"";i:0;s:4:""rate"";d:0.18;s:8:""currency"";s:3:""EUR"";}i:1;a:3:{s:6:""volume"";i:5000;s:4:""rate"";d:0.16;s:8:""currency"";s:3:""EUR"";}i:2;a:3:{s:6:""volume"";i:20000;s:4:""rate"";d:0.14;s:8:""currency"";s:3:""EUR"";}i:3;a:3:{s:6:""volume"";i:50000;s:4:""rate"";d:0.12;s:8:""currency"";s:3:""EUJe ziet waar het afhakt. De sluitende accolades zijn weg. De laatste "EUR" is afgehakt tot "EU. De header met a:6:{ belooft nog steeds zes staffel-objecten, maar alleen de eerste drie zijn intact. PHP, gevraagd om dat te unserializen, geeft false terug en gooit een E_NOTICE. De PHP-manual zegt het klip en klaar: elk karakter dat de byte-arithmetic om zeep helpt maakt de hele string ongeldig.
Van truncatie naar €0,00
De Shopify-kant had zijn eigen logica. We hadden de staffel-reader geport naar een kleine Cloudflare Worker die de Shopify Plus-storefront eens per maand aanriep, het bruto verkoopbedrag van de vendor binnenkreeg, en het toepasselijke tarief teruggaf. Die Worker las de staffel-data uit een Postgres-tabel die we vanuit de CSV hadden gevuld.
De CSV-import in Postgres faalde niet. Hij waarschuwde niet. Het veld was een text-kolom. Een afgekapte PHP-serialized string is nog steeds geldige tekst. De Worker, gevraagd om het tarief van een vendor, probeerde de blob te unserializen met een kleine PHP-array-naar-JS parser, kreeg null terug, viel door naar de default-branch, en die default-branch gaf 0 terug.
Voor 620 vendors stond het commissietarief op nul. De maandafrekening-run, die bruto omzet maal tarief rekent, produceerde keurig 620 afrekeningen van €0,00.
De eerste 90 minuten
Om 10:45 stond de Shopify Plus-storefront in maintenance mode voor de vendor portal-pagina's — de consumentenkant bleef gewoon online. Om 11:30 hadden we een werkende theorie. Om twaalf uur draaiden we deze query tegen de WordPress staging-database, die we gelukkig bevroren hadden gelaten:
SELECT user_id,
LENGTH(meta_value) AS bytes,
SUBSTRING(meta_value, -8) AS tail
FROM wp_usermeta
WHERE meta_key = 'dokan_commission_tiers'
ORDER BY bytes DESC
LIMIT 20;De langste serialized waardes in de WordPress-DB waren 4.210 bytes. De langste waardes in de CSV waren 4.096. Achteraf was de cap zo opzichtig. De CSV-bytes waren altijd een macht van twee als de data was afgekapt, en willekeurig als dat niet zo was. Ergens hield een buffer op met lezen.
De sample die de long tail miste
De CSV-samples waarop we de diff hadden gedraaid waren twee batches: 100 rijen en 500 rijen. Beide kwamen van de kop van de tabel, geordend op user ID. Die tabel was toevallig ruwweg chronologisch geordend, wat betekende dat onze samples bijna allemaal vendors waren die in 2019 en 2020 waren ingestapt, vóór het volume-staffelsysteem zich over de marketplace had verspreid. Hun serialized arrays hadden twee of drie brackets, ruim onder de chunk-buffer cap. De vendors met zes brackets en per-staffel currency overrides waren in 2023 aangesloten en stonden voorbij rij 1.500.
De les is niet dat 600 sample-rijen te weinig is. De les is dat een export-sample getrokken moet worden langs de dimensie die de export breekt. Bij deze data was de brekende dimensie de lengte van de serialized blob. Een bruikbare sample was geweest: de twintig langste rijen in de bron-tabel. ORDER BY LENGTH(meta_value) DESC LIMIT 20 had ons, in de allereerste batch die we hadden gedraaid, precies de data gegeven die er werkelijk toe deed. Random sampling en head sampling misten allebei de long tail, omdat de long tail per definitie zeldzaam is.
We behandelen de longest-row sample nu als een aparte, benoemde test in elke WordPress-migratie. Hij kost niets om te draaien. Hij had ons hier tien dagen gescheeld.
De fix die het plan had moeten zijn
We gooiden de CSV weg. We vervingen WP All Export door een directe mysqldump van de relevante rijen, gepiped in een klein Node-script dat één ding deed: unserialize elke rij, JSON-encode het resultaat, schrijf naar een nieuw bestand. Elke rij die niet te unserializen viel, stopte het script.
// dokan-tiers-export.js — runs once, fails loud
import { createReadStream } from "node:fs";
import readline from "node:readline";
import { unserialize } from "php-serialize";
const rl = readline.createInterface({
input: createReadStream("dokan_commission_tiers.tsv"),
crlfDelay: Infinity,
});
let n = 0, failed = 0;
for await (const line of rl) {
const [userId, blob] = line.split("\t");
try {
const tiers = unserialize(blob);
if (!Array.isArray(tiers)) throw new Error("not an array");
process.stdout.write(JSON.stringify({ userId, tiers }) + "\n");
} catch (err) {
process.stderr.write(`row ${userId}: ${err.message}\n`);
failed++;
}
n++;
}
process.stderr.write(`done · ${n} rows · ${failed} failed\n`);Failed: nul. De export was in 11 seconden klaar. We laadden de Postgres-tabel opnieuw vanuit de JSON, draaiden de Worker over de 620 nul-afrekeningen, genereerden 620 gecorrigeerde PDFs en stuurden ze met een excuus van één alinea en een link naar de herrekening. De totale commissie over die 620 afrekeningen was €184.720. De afwijking in beide richtingen na herrekening was €0.
Dat was dag elf. De migratie ging op dag twaalf live.
Het bericht dat we naar 620 vendors stuurden
De communicatiekant woog net zo zwaar als de fix. De eerste mail was om 03:00 CET de deur uit gegaan; vendors in Istanbul openden hem bij hun ontbijt. Tegen de tijd dat we een gecorrigeerde PDF hadden, hadden meerdere de €0,00-afrekening al doorgestuurd naar boekhouders, partners en in twee gevallen hun advocaat. We schreven een correctie van één alinea in het Nederlands en Engels, hingen de herrekende PDF erbij, benoemden de oorzaak zonder jargon ("een data-exportfout uit onze migratie"), en gaven het directe contact van de ops lead van de klant. We stuurden hem niet via een marketingtool. Hij ging als platte tekst-mail uit het eigen adres van de ops lead, met de 620 in BCC.
Drieëntwintig vendors antwoordden met het verzoek om de originele afrekening formeel uit hun boekhouding te trekken. Twee vroegen om een schriftelijke bevestiging van het gecorrigeerde bedrag op bedrijfsbriefpapier. Nul vroegen om de marketplace te verlaten. De zin die de ops lead achteraf bleef herhalen was dat een helder excuus beter landt dan een slim excuus. Die zin hebben we gepikt voor elk incident-comms template dat we sindsdien hebben geschreven.
Wat er in de migratie-checklist is veranderd
De les die we de volgende ochtend in onze interne playbook hebben gezet was concreet. Hij luidde:
Bij het migreren van WordPress-data die
serialize()-output bevat — elke plugin-meta, elkewp_options-rij, elke customwp_*-tabel — assertunserialize() !== falseop elke rij vóór de export en na de import. Een rijaantal in een spreadsheet is geen validatie.
We hebben ook drie regels aan de checklist toegevoegd:
- Vertrouw nooit een tool die zijn eigen chunking doet, tenzij je de chunking-code hebt gelezen. WP All Export, WP All Import, WP-CLI batch-flags, zelfs
mysqldump --extended-inserthebben edge cases. Lees de source, of zet de chunk-size op "geen chunking" en accepteer de langere run. - Dump de ruwe DB, niet een afgeleide export, voor elke kolom met serialized data.
mysqldump --whereis sneller, eerlijker en kapt nooit stil af. - Diff de byte-lengte van elke serialized kolom in de bron-DB en de doel-DB. Als een waarde bytes is kwijtgeraakt, is de migratie niet klaar.
Deze checks draaien nu op elke WordPress-migratie die we aanraken. Ze duren zes minuten op een WordPress-DB van 50.000 rijen en hebben sindsdien al twee andere plugins betrapt op hetzelfde gedrag: een Yoast SEO breadcrumb-config die op één site over onze buffer ging, en een custom WPML language-routes-tabel op een andere.
Het bredere punt over serialized PHP
PHP's serialize() is een soort binair formaat dat zich voordoet als tekst. De byte-tellers in de header (s:6:"volume" betekent "string van lengte 6, waarde volume") zijn geen hint, ze zijn deel van de spec. Eén byte missen en de hele string is junk. Het formaat is ouder dan de dominantie van JSON, en de meeste moderne tooling behandelt het als een opaque blob. Als jouw WordPress-site op een dag in een ander systeem gaat wonen, is elke serialized meta-rij een kleine schuld die ooit wordt ingelost.
Daarom is, als we nu een WooCommerce-naar-Shopify Plus migratie plannen, het eerste wat we doen — voordat iemand een themabestand aanraakt — een grep over wp_usermeta, wp_postmeta en wp_options op serialized kolommen, en een lijst van elke plugin die er een bezit. Twintig minuten greppen scheelt tien dagen brandblussen.
Als de CSV van een WordPress-migratietool er prima uitziet in een spreadsheet, heeft hij je niets verteld. De enige eerlijke check op serialized data is hem unserializen en de return inspecteren.
Toen we de post-mortem met de ops lead deden, was het stuk waar zij steeds op terugkwam dat de bug onzichtbaar was. Niets in de WordPress-logs, niets in de Shopify-logs, niets in het rapport van de export-tool. Het eerste signaal was een vendor-mail. Shopify Plus handelde de cutover keurig af; het falen zat upstream, in een zeven jaar oude WordPress-plugin die precies deed wat zijn auteur het volste recht had om te doen.
Voor je dit kwartaal aan een WordPress-migratie begint: open een MySQL-shell op de bron-DB en draai SELECT COUNT(*) FROM wp_usermeta WHERE meta_value LIKE 'a:%' OR meta_value LIKE 'O:%';. Het getal dat terugkomt is de grootte van het oppervlak dat je gaat migreren. Plan daarvoor in voor je de cutover boekt.
Kern
Als de CSV van een WordPress-migratietool er prima uitziet in een spreadsheet, heeft hij je niets verteld. Unserialize elke rij en assert dat de return niet false is.
FAQ
Waarom kapte WP All Export de Dokan-vendordata stilletjes af?
De default chunking van 1.000 rijen schrijft elke chunk naar een tijdelijke buffer met een per-field byte cap. Serialized waardes die boven die cap uitkomen worden afgeknipt, zonder dat er een waarschuwing volgt.
Hoe controleer ik of een WordPress-export serialized data heeft afgekapt?
Draai unserialize() over elke geëxporteerde rij en vergelijk de byte-lengte met de kolom in de bron-DB. Als unserialize false teruggeeft of de byte-tellers verschillen, is de rij afgekapt.
Is het veilig om serialized PHP-arrays in wp_usermeta te zetten?
Zolang je op WordPress blijft, werkt het prima. De pijn komt bij de migratie, wanneer elke downstream tool die de byte-arithmetic van PHP niet respecteert de waarde stil kan corrumperen.
Wat moet WP All Export voor dit soort data vervangen?
Een directe mysqldump gefilterd op meta_key, gepiped in een klein script dat elke rij met unserialize valideert en stopt bij de eerste failure. Trager, maar het kan niet tegen je liegen.