← Blog

Magento

Magento-migratie: elf dagen kwijt aan vier EAV-spoken

Een Hasseltse groothandel zag zijn Shopware 6-cutover elf dagen vastlopen door vier EAV-klantattributen die sinds de B2B-portal van 2017 niemand had gedocumenteerd.

Jacob Molkenboer· Oprichter · A Brand New Company· 12 jun 2026· 11 min
Open leren grootboek met vier koperen scheepslabels, groen lint en rood lakzegel op ivoorkleurig papier.

Dag 11 van het cutover-venster. De migration lead bij een groothandel in woonaccessoires (31 mensen) net buiten Hasselt was sinds 04:30 op. De Shopware 6-stagingshop stond klaar. De Magento 2.4.7-bron-dump was acht dagen oud en bevroren. De productcatalogus was overgezet. De orderhistorie stond in de nieuwe database. Betaalflows kwamen door elke smoke test. Het enige wat de nieuwe shop nog tegenhield, waren vier klantattributen die niemand binnen het bedrijf kon uitleggen.

Ze heetten bp_tier_v2, vat_rev_charge_flag, kvk_legacy_id en delivery_window_override. Geen van vieren stond in de Magento-admin onder Stores, Attributes, Customer. Geen van vieren werd genoemd in het actieve theme. Geen van vieren had ook maar één vermelding in de Confluence van het bedrijf. De developer die de oorspronkelijke B2B-portal in 2017 had gebouwd, was in 2019 vertrokken. Zijn opvolger in 2022. De huidige head of e-commerce had het systeem drie maanden geleden overgenomen en ging er, terecht, vanuit dat iets belangrijks ergens gedocumenteerd zou staan.

Dat was niet zo. De attributen bestonden als rijen in eav_attribute. Hun waarden stonden in customer_entity_varchar, customer_entity_int en customer_entity_text. Achttien van de top-twintig klanten van de groothandel hadden voor minstens één ervan een waarde die niet null was. En het migratiescript dat we hadden geschreven, hetzelfde script dat producten, orders en adressen perfect had gemapt, dropte stilletjes alle vier.

Wat EAV doet met een migratie

Het klantmodel van Magento is gebouwd op EAV: Entity, Attribute, Value. In plaats van een brede customers-tabel met één kolom per veld, staat de data uit elkaar. customer_entity bevat de entity-rijen. De attributen zijn gedefinieerd in eav_attribute. De waarden staan in smalle tabellen op datatype: customer_entity_varchar, customer_entity_int, customer_entity_decimal, customer_entity_datetime, customer_entity_text.

Dit is uitvoerig gedocumenteerd gedrag. Adobe's eigen referentie beschrijft het model in de EAV and Extension Attributes guide. Het voordeel van EAV: een nieuw attribuut toevoegen vereist geen schemawijziging. Het nadeel: een attribuut kan in de database bestaan zonder ooit in de admin-UI te verschijnen, zonder ooit te worden genoemd in een di.xml, en zonder ergens een spoor achter te laten waar een junior developer naar zou kijken.

Dat is precies wat hier was gebeurd.

Het script dat tegen ons loog

Onze eerste versie van de klantmigratie zag er ongeveer zo uit. Hij haalde elk attribuut met is_user_defined = 1 uit eav_attribute, joinde met de value-tabellen, en schreef Shopware custom fields uit.

<?php
$sql = <<<SQL
SELECT
    ea.attribute_code,
    ea.attribute_id,
    ea.backend_type,
    ea.is_user_defined,
    ea.is_visible
FROM eav_attribute ea
JOIN eav_entity_type et
    ON et.entity_type_id = ea.entity_type_id
WHERE et.entity_type_code = 'customer'
  AND ea.is_user_defined = 1
SQL;

$attributes = $db->query($sql)->fetchAll(PDO::FETCH_ASSOC);

Ziet er prima uit. Leest schoon. Kwam door de code review heen. De query gaf 23 rijen op de productie-dump. We hebben er 19 gemapt naar Shopware custom fields, de value-migratie geschreven, end-to-end gedraaid op staging, 14 voorbeeldklanten opengezet en gecheckt, en gingen die avond naar bed in de veronderstelling dat we bijna klaar waren.

De vier attributen die de cutover braken, werden door die query niet teruggegeven. Ze stonden op is_user_defined = 0. Die vlag betekent in Magento dat het attribuut geregistreerd is door het InstallData.php- of data_patch-script van een module, en niet doordat een admin in de UI op 'Add new attribute' klikte. De portal van 2017 was gebouwd als custom module. De attributen waren via een setup script geïnstalleerd. Vanuit de database gezien waren het systeemattributen, niet te onderscheiden van dob of gender.

Waarschuwing

Klantattributen in Magento filteren op is_user_defined = 1 mist elk attribuut dat door een custom module is geïnstalleerd. Bij elke shop ouder dan twee jaar is dit vrijwel altijd de verkeerde filter.

Hoe we ze vonden

De doorbraak kwam van een andere kant. Een van onze engineers draaide een query die een dommere vraag stelde: welke attribute-id's hebben daadwerkelijk waarden opgeslagen tegen echte klantrijen, ongeacht hoe ze zijn geregistreerd?

SELECT
    ea.attribute_code,
    ea.backend_type,
    ea.is_user_defined,
    COUNT(DISTINCT v.entity_id) AS customers_with_value
FROM eav_attribute ea
JOIN eav_entity_type et
    ON et.entity_type_id = ea.entity_type_id
LEFT JOIN customer_entity_varchar v
    ON v.attribute_id = ea.attribute_id
WHERE et.entity_type_code = 'customer'
GROUP BY ea.attribute_id
HAVING customers_with_value > 0
ORDER BY customers_with_value DESC;

Draai 'm één keer per value-tabel (varchar, int, text, decimal, datetime), union de resultaten, en je krijgt een compleet beeld van elk klantattribuut dat echt data bevat, ongeacht de herkomst. Die query gaf 27 attribute-codes terug. Drieëntwintig kwamen overeen met de admin-lijst. De andere vier waren onze spoken.

De cijfers vertelden de rest van het verhaal. bp_tier_v2 had waarden voor 412 klanten. vat_rev_charge_flag voor 89. kvk_legacy_id voor 731, méér dan het actieve klantenbestand, wat betekent dat het ook was teruggevuld voor inactieve accounts. delivery_window_override voor 14, maar die 14 waren de grootste klanten van de groothandel qua omzet. Het soort accounts dat direct merkt als hun dinsdagslot voor levering verdwijnt.

Betekenis reverse-engineeren

Weten dát de attributen bestonden was de makkelijke helft. Uitvinden wat elk attribuut betekende kostte twee dagen leeswerk.

kvk_legacy_id was de simpelste. KvK is de Kamer van Koophandel. Het veld bevatte het KvK-nummer van de klant in het formaat dat de portal van 2017 gebruikte, voordat Magento's standaard taxvat-veld er in 2019 voor werd hergebruikt. Een grep door de gedeployde module leverde de leeskant op: een oud factuurtemplate haalde nog steeds kvk_legacy_id op als taxvat leeg was. Drieëntwintig actieve klantaccounts leunden nog op die fallback. We bevestigden dat door een sample historische facturen opnieuw te renderen tegen de staging-database met het veld verwijderd. Elf ervan kwamen eruit met een leeg KvK-veld, wat volgens het AR-team automatische afwijzing zou hebben opgeleverd bij drie van de grotere franchiseklanten van de groothandel.

vat_rev_charge_flag was een boolean. Als hij aan stond, onderdrukte de checkout Belgische btw voor B2B-verkopen binnen de EU onder verlegging. De flag werd gelezen door een observer op sales_quote_address_collect_totals_after. Zonder dat de waarde meeverhuisde, zou elke verleggingsklant 21% btw hebben betaald op zijn volgende order, en het AR-team had een leuke ochtend gehad.

bp_tier_v2 was een tier-code: BRONZE, SILVER, GOLD, PLATINUM. Het stuurde via een custom catalog rule de keuze van de prijslijst aan. Het achtervoegsel 'v2' was een hint dat er ooit een v1 was geweest, die kennelijk vervangen was zonder de oude weg te halen. Een git blame op de afgeschafte module leverde de migratie op die v2 wegschreef.

delivery_window_override was de moeilijkste. De waarde was een JSON-blob; geen voor de hand liggende lezer in module, theme-template, observer-chain of queue-consumer waar we naar konden greppen. We traceerden hem door achteruit te werken vanaf de data. De value-tabel toonde eenendertig writes in de afgelopen twaalf maanden, allemaal tussen 09:00 en 11:00, allemaal vanaf dezelfde admin-gebruiker. Die admin-gebruiker bleek de magazijnchef te zijn, die het veld bewerkte via een intern grid-scherm dat in 2018 als onofficiële admin-extensie was gebouwd en nooit aan het hoofdmenu was toegevoegd. De leeskant was een nachtelijke cron job die de pickinglijsten voor de volgende dag bouwde en delivery_window_override raadpleegde voordat hij terugviel op de standaard van de klantgroep. Twee consumers, beide onzichtbaar voor een code grep. Geen van beide zou bovenkomen uit een admin-rondleiding.

Drie van de veertien accounts hadden malformed JSON in dat veld, geparst door json_decode zonder error handling. Die veertien accounts hadden jarenlang de juiste leverramen ontvangen door puur geluk: json_decode gaf null terug voor de kapotte rijen, de cron behandelde null als 'geen override', en de fallback van de groep matchte toevallig wat de magazijnchef bedoeld had. Die drie hebben we vóór de cutover gemarkeerd voor reparatie.

Mappen naar Shopware 6

Shopware 6 gebruikt geen EAV. Custom fields staan in custom_fields JSON-kolommen op entities, gedeclareerd via custom field sets in de admin of via plugin. De data-vorm is platter en het leespad sneller, maar de migratie dwingt je tot een keuze die het EAV-model je laat uitstellen: wat is het type van elk attribuut, en bij welke set hoort het?

We hebben de vier spoken gebundeld in één custom field set legacy_b2b_portal op de customer-entity, met de oorspronkelijke Magento-codes behouden als technische veldnamen. Daardoor blijft downstream SQL grep-baar. Bronze, Silver, Gold en Platinum werden een select-veld. De verleggingsflag werd een boolean. Het kvk-legacy-id werd tekst. De delivery-window-JSON hielden we als tekstveld, met een eenmalige validator die de drie malformed rijen markeerde zodat het AR-team ze handmatig kon repareren.

<?php
$customFieldSet = [
    'name' => 'legacy_b2b_portal',
    'config' => [
        'label' => ['en-GB' => 'Legacy B2B portal'],
        'translated' => true,
    ],
    'relations' => [
        ['entityName' => 'customer'],
    ],
    'customFields' => [
        [
            'name' => 'bp_tier_v2',
            'type' => CustomFieldTypes::SELECT,
            'config' => [
                'label' => ['en-GB' => 'B2B tier'],
                'options' => [
                    ['value' => 'BRONZE',   'label' => ['en-GB' => 'Bronze']],
                    ['value' => 'SILVER',   'label' => ['en-GB' => 'Silver']],
                    ['value' => 'GOLD',     'label' => ['en-GB' => 'Gold']],
                    ['value' => 'PLATINUM', 'label' => ['en-GB' => 'Platinum']],
                ],
            ],
        ],
        // three remaining fields elided for brevity
    ],
];

De custom fields waren de makkelijke helft. De catalog rule die op de tier draaide, had zijn eigen herbouw nodig. In Magento was de regel een custom condition class, geregistreerd via een module, die bp_tier_v2 van de klant las in de price collection flow. Shopware's rule builder accepteert customer-custom-field-condities standaard zodra het veld bestaat, waardoor de herbouw een configuratiekwestie werd in plaats van een code-kwestie. Eén regel per tier, elk verwijzend naar de bijbehorende prijslijst.

<?php
$rule = [
    'name' => 'Legacy B2B tier - Platinum',
    'priority' => 100,
    'conditions' => [
        [
            'type' => 'customerCustomField',
            'value' => [
                'operator' => '=',
                'selectedField' => 'legacy_b2b_portal_bp_tier_v2',
                'renderedField' => ['type' => 'select'],
                'value' => 'PLATINUM',
            ],
        ],
    ],
];

Totale tijd voor de herbouw van de regel-laag: zo'n vier uur, vrijwel volledig besteed aan verifiëren dat elk actief product nog op de juiste prijs per tier uitkwam. De verleggings-logica werd opnieuw geïmplementeerd als een kleine Shopware-subscriber op CheckoutOrderPlacedEvent die het booleanse custom field las en dezelfde btw-onderdrukking toepaste als de oude observer. De picking-job voor het leveringsraam werd herschreven als console-command op een nachtelijk schema.

Elf dagen te laat ging de cutover schoon de lucht in. Totale scope toegevoegd door de discovery: ongeveer 60 uur verdeeld over twee engineers en het AR-team.

De pre-flight check die we nu draaien

Na deze opdracht hebben we één stap toegevoegd aan elke Magento-replatform die we aanraken. Voordat er ook maar één migratiescript wordt geschreven, draaien we een attribuut-telling tegen de productie-readreplica. Het ziet er ongeveer zo uit.

SELECT
    et.entity_type_code,
    ea.attribute_code,
    ea.frontend_label,
    ea.backend_type,
    ea.is_user_defined,
    ea.is_required
FROM eav_attribute ea
JOIN eav_entity_type et
    ON et.entity_type_id = ea.entity_type_id
WHERE et.entity_type_code IN (
    'customer',
    'customer_address',
    'catalog_product',
    'catalog_category'
)
ORDER BY et.entity_type_code, ea.is_user_defined DESC, ea.attribute_code;

We printen het. We lopen het regel voor regel door met de senior persoon aan klantkant die de shop echt gebruikt. Elke regel krijgt één van vier markeringen. Houden betekent dat de waarde ergens wordt gelezen waar we het kunnen verifiëren, en het nieuwe platform heeft het equivalent nodig. Droppen betekent dat de waarde bestaat maar nergens wordt gelezen, en we hebben een paper trail die laat zien waarom. Vragen betekent dat iemand aan klantkant de naam herkent maar zich niet meer kan herinneren wat het ook alweer aanstuurde, en we openen een ticket om de oorspronkelijke developer of de afgeschafte module te traceren. Spook betekent dat niemand het herkent en we de database moeten greppen op aantallen waarden, write-timestamps en lezende code voordat we beslissen. Die ene review-sessie, een week voor cutover, zou alle vier de attributen in het Hasseltse oorlogsverhaal hebben gevangen. We hebben hem sindsdien op zes opdrachten gedraaid. Bij vijf ervan kwam er iets bovendrijven, waaronder een Drupal Commerce-shop waar een veld preferred_courier_v3 bleek aan te sturen welke koerier het hele weekend de dispatch deed.

Take-away

Bij elke Magento-shop ouder dan twee jaar: ga ervan uit dat er ongedocumenteerde EAV-attributen bestaan. Tel de database voordat je de admin-UI vertrouwt.

De les die we steeds opnieuw leren

Oude e-commerce-systemen zijn niet echt code, het is sediment. Elke laag werd afgezet door een developer die een echt probleem oploste onder een echte deadline. De meeste van die developers zijn weg. De staat die ze achterlieten zit stilletjes in EAV-tabellen, in observer chains, in cron jobs die niemand uitzet omdat niemand weet wat uitzetten zou doen. Een replatform die de bron als gedocumenteerd behandelt, ontdekt onvermijdelijk, elf dagen te laat, dat dat niet zo was. De eerlijke versie van elke migratie-inschatting heeft een discovery-regel die de klant niet volledig kan scopen en die het bureau niet volledig kan beloven. De overschrijding in Hasselt was onze herinnering: die regel is niet optioneel.

Toen we de Hasseltse shop replatformden, brak het bijna op de aanname dat de admin-UI een betrouwbare kaart van het klantmodel was. We hebben het opgelost door eerst naar de data te gaan en daarna pas naar de UI. Elke legacy migratie die we sindsdien hebben gedraaid, begint met dezelfde SQL-telling, en bij elke kwam iets boven dat de admin ons nooit had laten zien.

Draai je een Magento 2-shop die meer dan één developer heeft overleefd? Open vandaag een SQL-client, draai de attribuut-telling hierboven tegen je customer- en product-entity-types, en lees elke rij hardop voor. De ongedocumenteerde rijen zijn de rijen die je moet vinden vóór je migratie-venster begint, niet erna.

Kern

Bij elke Magento-shop ouder dan twee jaar: tel de EAV-tabellen voordat je de admin-UI vertrouwt. Spookattributen zijn de plek waar replatforms sneuvelen.

FAQ

Waarom miste de is_user_defined-filter die attributen?

Magento markeert attributen die via een setup- of data_patch-script van een module geregistreerd zijn als is_user_defined = 0. Alleen attributen die via de admin-UI zijn aangemaakt krijgen de vlag 1. Custom-module-attributen zien er voor die filter uit als systeemattributen.

Hoe vind ik elk klantattribuut dat echt data bevat?

Query eav_attribute samen met elke customer_entity_*-value-tabel op attribute_id, groepeer per attribute_id met een HAVING COUNT > 0. Union over de varchar-, int-, text-, decimal- en datetime-tabellen voor volledige dekking.

Gebruikt Shopware 6 EAV zoals Magento?

Nee. Shopware 6 slaat extensions op in een custom_fields JSON-kolom op elke entity, gedeclareerd via custom field sets. Migreren betekent per attribuut een concreet type en set kiezen, en niet de schemakeuze uitstellen.

Wat doe ik met legacy attributen die niemand kan uitleggen?

Niet blind weggooien. Markeer ze als spook, tel hoeveel rijen waarden bevatten, grep door codebase en templates op leeskanten, en loop de lijst door met wie de shop dagelijks runt voordat je beslist of je 'm houdt of dropt.

magentomigrationphpmysqlcase studye-commerce

Iets bouwen?

Start een project