← Blog

Magento

Van Magento naar Shopware: de regel die altijd matchte

Dag elf van de migratie. De projectleider at koude pasta achter haar bureau en staarde naar een servet van €4,20 dat in Shopware staging stilletjes voor €2,60 was afgerekend.

Jacob Molkenboer· Oprichter · A Brand New Company· 5 sep 2025· 10 min
Open leren grootboek op ivoren bureau, koperen labels aan linnen touw, opgekrulde bon met groen prijskaartje, rode lakzegel.

Dag elf van de migratie. Op het kantoor in Almere deed niemand nog alsof dit een normale sprint was. Een B2B-leverancier in horecabenodigdheden (19 mensen, eigen merkportaal, zo'n €8M ARR) zat drie weken over hun go-live datum op Shopware 6.6. De projectleider at koude pasta achter haar bureau en staarde naar één order in staging. Een gloednieuwe anonieme bezoeker. Eén pak papieren servetten. €4,20 adviesprijs. €2,60 bij de checkout. Een korting van 38% die niemand in het team had ingesteld, geschreven of zelfs maar gezien.

De staging-Shopware was opgebouwd uit een run van de Migration Assistant tegen hun Magento 2.4.6 als bron. Acht testorders waren doorgelopen. Drie QA-accounts, allemaal in B2B-klantgroepen, allemaal groen. De negende order was de eerste uitgelogde. Daarna klopte er niets meer.

Het ding in catalog_rule

Hun Magento-installatie was acht jaar oud. In 2018 had een freelance-ontwikkelaar (al lang verhuisd naar Berlijn) een custom module geleverd genaamd Acme_QuoteRules. Die kaapte Magento's catalog price rule engine om hun B2B-offertelogica aan te sturen. In plaats van een nieuwe entity te bouwen had hij Magento\CatalogRule\Model\Rule\Condition\Combine uitgebreid met een stuk of zes custom condition-classes: Acme\QuoteRules\Condition\CustomerSegment, Acme\QuoteRules\Condition\AnnualSpend, Acme\QuoteRules\Condition\SkuGroup, en andere. De hele condition-tree werd, zoals Magento dat altijd doet, opgeslagen in catalog_rule.conditions_serialized als een PHP-serialize()-blob.

Zo zag een van die rijen er ongeveer uit, gedecodeerd:

// catalog_rule.conditions_serialized (decoded, abbreviated)
O:46:"Acme\QuoteRules\Condition\Combine":4:{
  s:9:"\0*\0_type";        s:7:"combine";
  s:14:"\0*\0_aggregator"; s:3:"all";
  s:9:"\0*\0_value";       i:1;
  s:14:"\0*\0_conditions"; a:3:{
    i:0; O:50:"Acme\QuoteRules\Condition\CustomerSegment":...;
    i:1; O:46:"Acme\QuoteRules\Condition\AnnualSpend":...;
    i:2; O:44:"Acme\QuoteRules\Condition\SkuGroup":...;
  }
}

Bij elke cart-load in Magento werd die blob unserialized, de tree doorlopen, en er werd een korting van 38% per regel toegepast als alle drie de custom condities matchten. Klant in segment 'wholesale', lifetime spend boven €15k, SKU-prefix uit een vaste lijst van bulkverpakkingen. Acht jaar lang gedraaid zonder één incidentmelding. De oorspronkelijke ontwikkelaar was weg. De halve pagina documentatie was een Confluence-link die naar een 404 leidde.

Wat Shopware's Migration Assistant ermee doet

Shopware's Migration Assistant mapt Magento-entities naar Shopware-entities. Customers gaan naar customer. Producten naar product. Magento-prijsregels naar de Shopware-promotion-entity, die zijn eigen Rule Builder heeft. Waar Magento een PHP-serialized tree opsloeg, slaat Shopware een JSON-tree op die is opgebouwd uit AndRule, OrRule, LineItemOfTypeRule en vrienden.

Voor de standaard Magento-condition-classes heeft de converter expliciete mappers. Voor onbekende classes, zoals onze Acme\QuoteRules\Condition\*-set, valt hij terug. De vorm van die fallback, vereenvoudigd uit de converter-source:

// Migration converter, simplified
$tree = @unserialize($row['conditions_serialized']);

if ($tree instanceof \__PHP_Incomplete_Class) {
    $this->logger->warning('Unknown condition class, using empty rule', [
        'rule_id' => $row['rule_id'],
    ]);
    return new AndRule(); // empty AND, evaluates to TRUE
}

Een lege AndRule in Shopware evalueert naar true. Een promotion met een true rule matcht elke cart. Een korting van 38% per regel die overal werd toegepast. De migration-log droeg één warning-regel per geraakte regel, 47 in totaal. Niemand las die regels, want de migratie eindigde met een success exit code en de eerste ronde testorders zag er prima uit.

Les

De success-code van een migratietool betekent dat de import klaar is. Niet dat de business logic heeft overleefd. De semantiek van een serialized PHP-blob is niet de verantwoordelijkheid van de migrator. Die is van jou.

Waarom staging acht dagen lang doorging

Dit was het stuk dat het team bleef herkauwen. Hun staging-QA-matrix was vier jaar oud. Hij testte drie scenario's: een ingelogd wholesale-account dat een bulkorder plaatste, een ingelogd retail-account dat één item kocht, en een ingelogd retail-account dat een cart liet liggen. Alle drie de accounts zaten in klantgroepen die de migrator correct had gemapt. Dus elke QA-order was óf een klant waarbij de 38% korting toevallig verwacht was, óf een product buiten de SKU-prefix-lijst waar de oorspronkelijke regel stilletjes een inner check had laten falen, óf een wholesale-account waar een hoger geprioriteerde regel de kapotte overschreef.

De matrix testte nooit de meest voorkomende toestand op een live store: anoniem browsen. Niemand had bedacht om een test 'uitgelogde bezoeker koopt een servet' te schrijven, want in acht jaar Magento was die case oninteressant. Na de migratie was het de enige case die telde, en het was de enige case die niemand draaide.

De migration-log maakte het makkelijk om dezelfde fout te herhalen. De Shopware-import had alleen al op de customer-attribute-pass 230 warnings gegooid, allemaal goedaardig, allemaal verwacht door de manier waarop Magento's open customer_entity_varchar-attributen op Shopware's getypte schema mappen. Het team had in week twee al geleerd om het warning-kanaal door te scrollen. Tegen week drie was alles wat geel was achtergrond. De 47 regels die er wel toe deden zaten tussen rijen die er niet toe deden.

Elf dagen de verkeerde laag debuggen

De eerste drie dagen gingen op aan Shopware zelf. Ze gingen ervan uit dat er een verkeerd geconfigureerde promotion in de geïmporteerde set zat, openden er één voor één in de admin, en zagen wat redelijke regels leek. Klantgroep, sales channel, geldigheidsperiode. Niets zei 'altijd waar'. De admin-UI rendert een lege AndRule niet als 'matcht alles'. Hij rendert hem als een lege condition-groep, wat het team las als 'de conditie is wat ik hier instel', niet als 'de conditie is al voldaan voor elke cart in je winkel'.

Dag vier tot en met zeven gingen op aan de Rule Builder-cache. Ze leegden, herbouwden, herstartten, importeerden een subset opnieuw. De bug bleef precies waar hij zat. Ze openden een support-ticket. Ze schreven een eigen logger. Ze diffden de promotion-JSON tegen een handmatig gebouwde referentieregel. De diff was schoon. De referentieregel matchte de servet-cart niet. De geïmporteerde regel wel.

Ergens daarin ging een halve dag op aan de Shopware-tax-engine. Het getal 38% lag verdacht dicht bij het omgekeerde van hun 21% BTW dubbel toegepast, dus de lead developer joeg een afrondingsspook door de netto-naar-bruto pipeline voordat hij doorhad dat de korting een line-item delta was, geen tax-artefact. De trace was schoon. De hypothese klopte niet.

Op dag acht zag de enige ontwikkelaar die de converter-source had gelezen de warning-regels in de migration-log staan. Het waren er 47, één per geïmporteerde promotion die naar een Acme-class verwees. Hij pakte de source, traceerde de fallback, en in de kamer werd het stil. De fix was nu een scope-gesprek, geen debug-sessie meer.

De fix die wel werkte

Twee opties lagen op tafel. Een nette converter-extensie bouwen die wist hoe Acme\QuoteRules-classes naar Shopware-Rule-Builder-nodes vertaald moesten worden, of de overlevende regels met de hand opnieuw opbouwen in de Shopware-admin. Ze probeerden eerst de converter-route.

De extensie moest twee dingen doen. De serialized blob lezen zonder de ontbrekende Acme-classes te instantiëren (een controlled parse, geen rauwe unserialize), en de resulterende tree vertalen naar Shopware's Rule-JSON. De eerste helft werkte. De tweede helft liep tegen een klasse condities aan die de Rule Builder niet kan uitdrukken. Acme\AnnualSpend raakte bij elke cart-evaluatie een stored procedure. De dichtstbijzijnde Shopware-equivalent is een custom Rule als plugin, geëvalueerd bij cart-load, met een eigen caching-laag om te voorkomen dat de database bij elke page-render gehamerd wordt. Dat is echt engineering-werk, geen één-dag-port. Acme\SkuGroup las zijn toegestane prefixes uit een config-XML die door drie mensen in zes jaar was bewerkt en de eerste vier jaar niet onder versiebeheer stond.

Dus deden ze het saaie ding. Ze printten de 47 regels uit een eenmalige Magento-export-query:

SELECT
  cr.rule_id,
  cr.name,
  cr.simple_action,
  cr.discount_amount,
  cr.conditions_serialized
FROM catalog_rule cr
WHERE cr.is_active = 1
  AND cr.conditions_serialized LIKE '%Acme\\\\QuoteRules%'
ORDER BY cr.sort_order;

Ze liepen de export door met het hoofd verkoop, die de enige bleek te zijn die de business-intent van elke regel kende. De meeste waren dood. De SKU-prefix-lijst verwees naar producten die in 2021 uit het assortiment waren gehaald. De CustomerSegment-waarden wezen naar segmenten die in een reorganisatie in 2023 waren samengevoegd. Twaalf regels overleefden het gesprek. Die twaalf bouwden ze met de hand opnieuw op in de Shopware Rule Builder, met screenshots van elke regel vastgepind in een gedeeld document zodat het institutionele geheugen eindelijk ergens leesbaars woonde. De andere 35 regels werden uit de Shopware-import verwijderd. Dag elf eindigde met een schone testronde op anonieme, B2C- en B2B-carts. Go-live was vier werkdagen later.

Wat je voor de start eigenlijk moet checken

Zit je op een Magento-winkel ouder dan vier jaar met een Shopware-migratie op je roadmap, dan is de audit kort en de moeite waard om vandaag te draaien.

Open je Magento-database. Draai dit tegen catalog_rule. Elk niet-nul resultaat betekent dat een custom of third-party module zijn eigen condition-classes in de rule-engine heeft geschreven, en de Migration Assistant zal die niet correct verwerken:

SELECT COUNT(*) AS custom_condition_rules
FROM catalog_rule
WHERE conditions_serialized NOT LIKE '%Magento\\\\%'
   OR actions_serialized    NOT LIKE '%Magento\\\\%';

Doe daarna hetzelfde tegen salesrule, dat dezelfde vorm en hetzelfde probleem heeft. De action-blob is net zo belangrijk als de condition-blob, want Magento laat een cart-price-rule ook een custom action-class dragen, en die zal de migrator stilletjes weglaten:

SELECT COUNT(*) AS custom_condition_salesrules
FROM salesrule
WHERE conditions_serialized NOT LIKE '%Magento\\\\%'
   OR actions_serialized    NOT LIKE '%Magento\\\\%';

Lijst vervolgens elke non-Magento, non-Mage namespace onder app/code. Elk daarvan is een module die de rule-engine, de checkout of de customer-save-pipeline gehooked kan hebben, en elk daarvan is een aparte vraag voor het migratieplan:

find app/code -maxdepth 2 -mindepth 2 -type d \
  | grep -Ev '/(Magento|Mage)/' \
  | sort

Grep dan elke events.xml in die modules op de drie observer-points die stilletjes carts en orders herschrijven: checkout_cart_save_after, sales_order_place_after, catalog_product_save_after. Wat daar afgaat is business-logic die de Migration Assistant niet ziet en niet kan porten. Die port je met de hand of je budgetteert de rebuild.

Levert een van die checks iets op, schrijf dan waar mogelijk de converter-extensie, of budgetteer twee dagen sales- en engineering-tijd om de regels met de hand opnieuw op te bouwen in Shopware. Ga er niet van uit dat de migration-warning-log gelezen wordt. Dat gebeurt niet.

Het bredere patroon

Een artikel dat vorige week rondging beweerde dat de enige schaalbare DELETE in Postgres DROP TABLE is. De framing is smal, maar de bredere gedachte is wat dit team de harde manier opnieuw leerde. Databases stapelen logica op die niemand heeft gedocumenteerd en niemand bezit. Een serialized PHP-blob in catalog_rule is geen rij data. Het is een stuk executable business-policy uit 2018 waarvan de auteur naar Berlijn is verhuisd en wiens functietitel 'freelance Magento-dev' was. Op de dag dat je hem migreert ben jij verantwoordelijk voor wat hij doet, of je nu wist dat hij bestond of niet.

Wanneer wij voor een klant een migratie weg van Magento draaien, is de eerste dag forensisch, niet architectonisch. We greppen conditions_serialized. We lijsten elke namespace onder app/code. We lezen de changelog van elke observer die hangt aan checkout_cart_save_after, sales_order_place_after en catalog_product_save_after. We draaien een anonieme-cart-test op staging op dag drie, niet dag elf, want het goedkoopste uur van een migratieproject is het uur dat je doorbrengt voordat je begint.

Toen we vorig voorjaar een vergelijkbare audit deden voor een Nederlandse B2B-groothandel was de verrassing niet de custom price rules. Het was een shipping-method-observer die de rule-engine volledig omzeilde en het cart-totaal patchte tijdens checkout_cart_save_after. De fix was rechttoe rechtaan zodra we wisten waar we moesten kijken. Dit soort legacy-migratiewerk doen we regelmatig, en het patroon heeft altijd dezelfde vorm: de regel die productie sloopte is geschreven door iemand die niet meer in de kamer zit.

De vijf-minuten-audit

Open je Magento-DB. Draai de twee SQL-queries hierboven. Lijst de non-Magento namespaces onder app/code. Komt een van de drie checks met resultaten terug, begin dan niet aan je Shopware-migratie zonder converter-plan of rebuild-plan. Dat is de hele opdracht.

Kern

De success-code van een migratietool betekent dat de import klaar is. Niet dat de business logic heeft overleefd. Serialized PHP-blobs zijn jouw verantwoordelijkheid, niet die van de migrator.

FAQ

Converteert de Migration Assistant van Shopware custom Magento-catalog-rule-condities?

Nee. Hij mapt de standaard Magento-condition-classes, maar valt terug op een lege Shopware-AndRule zodra hij een custom of third-party condition-class tegenkomt. Een lege AndRule evalueert naar true, wat betekent dat elke cart matcht.

Hoe check ik of mijn Magento-winkel custom catalog-rule-condities heeft?

Draai SELECT COUNT(*) FROM catalog_rule WHERE conditions_serialized NOT LIKE '%Magento\\%'. Elk niet-nul resultaat betekent dat er een custom class in het spel is. Doe hetzelfde tegen de salesrule-tabel en check ook de kolom actions_serialized.

Logt de Migration Assistant deze fallback ergens?

Ja, als een warning-regel per geraakte regel in de migration-runtime-log. De migratie eindigt nog steeds met een success-code, dus de warnings worden zelden gelezen totdat er iets in productie kapotgaat.

Is het veiliger om een converter-extensie te schrijven of de regels met de hand opnieuw op te bouwen?

Als de oorspronkelijke business-intent gedocumenteerd is en de custom classes simpel zijn, schrijf de converter. Is de documentatie weg en is het aantal regels klein (onder de vijftig), bouw ze dan met de hand opnieuw op, met de business-eigenaar erbij in de kamer.

magentomigrationphplegacy sitese-commercecase study

Iets bouwen?

Start een project