← Blog

Magento

Magento naar Shopware migratie: 11 dagen vast op EAN-13

Op dag drie scrolde de catalog-lead naar een productpagina en zag een JSON-object waar de productnaam hoorde. 47.200 SKU's. Dezelfde bug. Elf dagen om 'm te vinden.

Jacob Molkenboer· Oprichter · A Brand New Company· 14 jun 2026· 9 min
Open leren scheepvaartlogboek op ivoor papier met messing vrachtlabel, linnen touw, groen lint, rood lakzegel.

Op dag drie opende Anouk een productpagina in de Shopware-staging en stopte met scrollen. Het veld productnaam toonde {"value":"8714026034817","scope":"global"}. Ze ververste. Zelfde string. Ze pakte willekeurig een andere SKU. Zelfde vorm, andere EAN. Ze pakte een derde. Hetzelfde.

De migratie moest die vrijdag live. We waren met acht engineers vanuit ABN, tien aan de kant van de klant in Rotterdam, en we hadden net ontdekt dat 47.200 productnamen in Shopware JSON-objecten waren die zich voordeden als namen. Niemand had het naamveld aangeraakt. Niets in het migratieplan haalde de naam ergens vreemd vandaan. En toch stond het er, herhaald, voorspelbaar, met de EAN-13 van elke SKU die door de structuur terugkeek.

Dit is de post-mortem. Elf dagen om te vinden, twee uur om te fixen, en de rest van het jaar om niet meer te doen alsof het niet opnieuw kon gebeuren.

De catalogus in Magento voor we eraan begonnen

De shop was een Rotterdamse B2B-distributeur op Magento 2.4.6 met PHP 8.2, rond de 6.800 parent-producten en 47.200 SKU's als je configurables en hun children meetelde. De catalogus was eerder al een keer gemigreerd, vanaf Magento 1.9 in 2019, en had de gebruikelijke aanslag verzameld: zwervende EAV-attributen, een custom ean_13-attribuut op elk product, drie verschillende product-type-definities voor wat één type had moeten zijn, en een PIM die 's nachts naar Magento schreef.

De PIM draaide een Akeneo-achtige flow. Een community-onderhouden connector pollde de PIM API elke zes uur, pakte de product-payload en duwde die via de REST API in Magento. De versie die in composer.json stond, was sinds 2022 niet meer bijgewerkt.

De Shopware-target was 6.6 LTS op PHP 8.3. We gebruikten de Migration Assistant van Shopware als ruggengraat, met een custom plugin die de product converter overschreef, zodat de specifieke attribute-mapping van de klant (manufacturer SKU, EAN-13, customs codes, energielabel-PDF's) in de juiste Shopware-velden terechtkwam. De custom converter draaide schoon op een test-export van 200 producten. We gaven groen licht voor de volledige pull op een dinsdagavond.

Het eerste teken dat er iets niet klopte

Woensdagochtend was de import klaar. De Shopware-admin liet het juiste aantal producten zien. De categorieboom zag er goed uit. Voorraadcijfers klopten. We zetten 'm op groen en gaven staging door aan het catalog-team van de klant voor sign-off.

Anouk was hun catalog-lead. Ze opende de storefront, koos een product zoals een klant zou doen, en in plaats van Rolgordijn verduisterend 120x180 antraciet zag ze een JSON-object. Om 10:14 had ze een Slack-draad open met screenshots van vier producten. Om 11:00 hadden we bevestigd dat het elk product was. Om 12:30 hadden we onszelf in drie verkeerde theorieën gepraat over het waarom.

Drie verkeerde theorieën voor de juiste

De verkeerde theorieën waren allemaal redelijk en allemaal duur.

Theorie één: de converter overschreef name vanuit een custom attribute. We lazen de converter regel voor regel terug. De name-mapping was de default Shopware-mapping. Hij las uit catalog_product_entity_varchar waar attribute_id matchte met name. Niets exotisch. We voegden logregels toe, draaiden een test van 50 producten opnieuw, en de namen kwamen schoon door. De converter was dus niet de bug.

Theorie twee: een post-import job herschreef namen vanuit EAN. Iemand aan klantzijde had ooit een script geschreven om namen vanuit PIM-exports terug te schrijven. We grepten ernaar, vonden 'm, bevestigden dat hij uitstond in de cron-tabel, en draaiden de import opnieuw met de cron daemon volledig gestopt. Dezelfde JSON in het naamveld. De post-import job was dus niet de bug.

Theorie drie: de Migration Assistant van Shopware had een bug. We openden een ticket. Het Shopware-team kwam binnen een dag terug met de beleefde vraag of we onze brondata hadden gecheckt. We protesteerden inwendig. En toen checkten we onze brondata.

De query die het debat beslechtte

Het Magento name-attribuut leeft in catalog_product_entity_varchar. De rij die we verwachtten te zien was een Nederlandse productnaam in schone UTF-8. De rij die we daadwerkelijk zagen was deze:

SELECT entity_id, value
FROM catalog_product_entity_varchar
WHERE attribute_id = 73
  AND store_id = 0
LIMIT 5;
+-----------+--------------------------------------------------------------+
| entity_id | value                                                        |
+-----------+--------------------------------------------------------------+
|     10412 | "{\"value\":\"8714026034817\",\"scope\":\"global\"}"          |
|     10413 | "{\"value\":\"8714026034824\",\"scope\":\"global\"}"          |
|     10414 | "{\"value\":\"8714026034831\",\"scope\":\"global\"}"          |
|     10415 | "{\"value\":\"8714026034848\",\"scope\":\"global\"}"          |
|     10416 | "{\"value\":\"8714026034855\",\"scope\":\"global\"}"          |
+-----------+--------------------------------------------------------------+

Het name-attribuut in Magento was een JSON-encoded JSON-object waarvan het enige zinvolle veld een EAN-13 was. De Migration Assistant had het één keer gedecodeerd op weg naar buiten, de gedecodeerde innerlijke string als productnaam weggeschreven, en was verdergegaan met z'n dag. De bug zat upstream, in de data die we vanuit PIM hadden geïmporteerd, weken voordat we Shopware ook maar hadden aangeraakt.

De dubbele wrap in de PIM-connector

De AttributeMapper van de connector normaliseerde elke waarde naar een wrapper-vorm voordat hij die naar Magento duwde:

public function pack(string $code, $raw): string
{
    return json_encode([
        'value' => $raw,
        'scope' => $this->scopeFor($code),
    ]);
}

De PIM exporteerde de EAN-13 op twee plekken. De eerste export kwam vanuit het product-attribuut zelf, met de ruwe waarde 8714026034817. Die waarde ging één keer door pack(), werd een JSON-string en werd naar Magento geduwd als het ean_13-attribuut. Correct, ook al was het lelijk.

De tweede export kwam vanuit een custom "name override"-regel die de klant twee jaar eerder had opgezet. De regel zei: als het product geen Nederlandse vertaling heeft in de PIM, val terug op de EAN. Wie die regel had geschreven, had niet door dat de fallback de al-gepackte waarde uit een transient cache las, niet de ruwe EAN. De connector pakte dus {"value":"8714026034817","scope":"global"} uit cache, haalde 'm een tweede keer door pack(), en duwde de dubbel-geëncodeerde string in het name-veld van Magento.

Twee jaar lang viel het niemand op. De storefront van Magento toonde de productnaam vanuit de Nederlandse store view, die een nette vertaling had, en de naam op global-scope werd nooit gerenderd. De EAN-export naar Google Shopping las uit ean_13, niet uit name. De fallback hoefde nooit terug te vallen.

Toen migreerden we naar Shopware, die de naam op global-scope als eerste leest.

Let op

Migratietools zijn alleen zo eerlijk als de data die ze lezen. Een veld dat jarenlang stil kapot is in productie, kan z'n stilte recht het nieuwe systeem in dragen, waar het oppervlak dat het verborgen hield niet meer bestaat.

De audit die 47.200 rijen vond

Toen we eenmaal wisten waar we naar zochten, was de audit vier uur SQL en een koffie. We schreven een one-shot query die elke varchar-attribuutwaarde markeerde waarvan de inhoud begon met { en parste als JSON. We draaiden 'm tegen elke store view.

SELECT
  a.attribute_code,
  cpev.entity_id,
  cpev.store_id,
  cpev.value
FROM catalog_product_entity_varchar cpev
JOIN eav_attribute a ON a.attribute_id = cpev.attribute_id
WHERE cpev.value LIKE '{%'
  AND JSON_VALID(cpev.value) = 1;

Het resultaat: 47.200 rijen in name, allemaal op de global store view. Nul rijen op de Nederlandse view, nul op de Engelse, nul op de Duitse. De PIM had altijd alleen naar global geschreven. De vertaalredacteuren hadden twee jaar lang stilletjes het symptoom gefixt zonder de wond te kennen.

De rollback en de transform

We hadden twee opties. De Magento-bron fixen voordat we de migratie opnieuw draaiden, of het fixen in de converter op weg Shopware in. We kozen voor de converter, omdat de klant geen Magento-code wilde redeployen op een productiesysteem dat ze over twee weken zouden uitfaseren.

De converter-override voegde een guard toe. Voordat we het name-veld schreven: parse het. Als het geldige JSON is met een value-key, pak de value. Als die value zelf weer JSON is, parse nogmaals. Loop door de lagen tot het resultaat een platte string is, en check dan of het er als een EAN-13 uitziet (13 cijfers, geldige GS1-checksum volgens de EAN-13-spec). Zo ja, val terug op de gelokaliseerde naam uit de Nederlandse store view. Als ook de Nederlandse naam ontbreekt, val terug op "SKU " + sku en log de rij.

private function unpackName(string $raw, string $sku, ?string $dutch): string
{
    $value = $raw;
    for ($i = 0; $i < 3; $i++) {
        $decoded = json_decode($value, true);
        if (!is_array($decoded) || !isset($decoded['value'])) {
            break;
        }
        $value = (string) $decoded['value'];
    }

    if (preg_match('/^\d{13}$/', $value) && $this->isValidEan13($value)) {
        return $dutch ?: 'SKU ' . $sku;
    }

    return $value;
}

We capten de unwrap-lus op drie lagen, als veiligheid bovenop de veiligheid. Alles dieper dan dat was een teken van een andere bug, en we wilden hard falen in plaats van eeuwig recursen.

De her-import duurde zes uur. Anouk gaf de volgende ochtend sign-off. De migratie ging 14 dagen achter op de oorspronkelijke datum live, waarvan 11 dagen voor de bug en 3 dagen voor de inhaalslag op parallel werk dat had vastgezeten achter sign-off.

Wat we in onze migratie-playbook hebben aangepast

De elf dagen gingen niet echt over EAN-13. Ze gingen over hoeveel vertrouwen we in een sample van 200 producten stopten. 200 producten waren erdoor gekomen omdat de bug alleen op de global store view bestond, en ons sample was getrokken uit de Nederlandse store view (waar het catalog-team dagelijks werkte en waar de testdata er het schoonst uitzag). Pure sample bias.

We samplen nu op drie manieren bij elke Magento-migratie:

  • 200 producten uit de meest actieve store view, zoals we altijd al deden.
  • 200 producten uit elke andere store view, inclusief de global view, ook als de klant ons vertelt "global gebruikt niemand".
  • 50 producten via SQL geselecteerd op anomalieën: waardes die beginnen met {, waardes langer dan 2x de mediaan voor hun kolom, waardes die over 100+ rijen exact identiek zijn.

De derde bucket is degene die dit in een middag had gevangen. We draaien ook JSON_VALID over elk varchar- en text-attribuut, en brengen elke kolom naar boven waarin meer dan 0,1% van de rijen als JSON parst. Magento slaat JSON niet by design op in EAV. Als JSON er staat, heeft iets upstream het er bewust ingezet, en willen we weten wat.

Toen we de Shopware 6-cutover voor deze Rotterdamse klant bouwden, was het ding waar we tegenaan liepen een twee jaar oude PIM-bug die we vanuit de brief niet hadden kunnen voorspellen. We losten 'm uiteindelijk op met een unwrap op converter-niveau en een sampling-regel die we nu toepassen op elke legacy migratie die we aannemen. Die regel kost een halve dag per project. 'm niet hebben kostte er elf.

Als je op een Magento-catalogus zit die je dit jaar wil migreren, is het kleinste nuttige dat je vandaag kunt doen: SELECT COUNT(*) FROM catalog_product_entity_varchar WHERE value LIKE '{%' AND JSON_VALID(value) = 1; draaien tegen je productie-database. Komt er iets anders dan nul uit, dan heb je een gesprek te beginnen met wie ook al je PIM-connector heeft geschreven.

Kern

Sample je migratie uit elke store view, niet alleen de drukke. De bug in global scope rijdt mee op het moment dat je nieuwe systeem global als eerste leest.

FAQ

Hoe komt dubbele serialisatie in een Magento-attribuut terecht?

Een connector wrapt een waarde in JSON voor transport, daarna leest een fallback-regel de al-gewrapte waarde uit cache en wrapt 'm nog een keer. Magento's EAV slaat het resultaat op als string omdat het attribuut varchar is.

Waarom heeft de storefront de kapotte naam twee jaar lang niet gevangen?

De slechte waarde stond op de global store view. Elke gelokaliseerde store view renderde z'n eigen vertaalde naam, dus het symptoom verscheen alleen in velden die global als default lazen, en dat deed de storefront nooit.

Is de Migration Assistant van Shopware het verkeerde tool voor grote Magento-catalogi?

Nee. Het tool deed precies wat hem werd opgedragen. Onze converter haalde name uit het Magento global-scope-attribuut, en Shopware schreef weg wat het vond. De fix zit in de converter en in een brondata-audit, niet in de assistant.

Wat is de snelste pre-migratie-check voor dit soort bugs?

Draai JSON_VALID over elk varchar- en text-attribuut. Elke kolom waar meer dan een fractie van een procent van de rijen als JSON parst, is een kolom waar upstream code iets in heeft geschreven dat Magento niet bedoeld was om vast te houden.

Fixen we de brondata of fixen we de converter onderweg?

Fix de converter als het bronsysteem dagen verwijderd is van uitfasering en een redeploy duur is. Fix de bron als de kapotte data na de migratie nog door andere consumers gelezen wordt.

magentomigrationlegacy sitese-commercearchitecturecase study

Iets bouwen?

Start een project