← Blog

Magento

Magento 2.4.6 to Shopware 6.6: the always-true rule

Day eleven of the migration. The lead was eating cold pasta at her desk, staring at a €4.20 napkin that had quietly rung up at €2.60 in Shopware staging.

Jacob Molkenboer· Founder · A Brand New Company· 16 Jun 2026· 10 min
Open leather ledger on ivory desk, brass tags on linen twine, curled receipt with green price tag, red wax seal.

Day eleven of the migration. The Almere office had stopped pretending it was a normal sprint. A 19-person B2B catering-supplies vendor running a private-label ordering portal, around €8M ARR, was three weeks past their go-live date on Shopware 6.6, and the project lead was eating cold pasta at her desk while staring at a single staging order. A brand-new anonymous visitor. One bag of paper napkins. €4.20 list price. €2.60 at checkout. A 38% discount that nobody on the team had configured, written, or even seen before.

They had built the staging Shopware off a Migration Assistant run from their Magento 2.4.6 source. Eight test orders had passed. Three QA accounts, all in B2B customer groups, all green. The ninth order was the first one logged-out. Then everything was wrong.

The thing in catalog_rule

Their Magento install was eight years old. In 2018, a freelance developer (long since moved to Berlin) had shipped a custom module called Acme_QuoteRules that hijacked Magento's catalog price rule engine to power their B2B quote logic. Instead of building a new entity, he had extended Magento\CatalogRule\Model\Rule\Condition\Combine with a half-dozen custom condition classes: Acme\QuoteRules\Condition\CustomerSegment, Acme\QuoteRules\Condition\AnnualSpend, Acme\QuoteRules\Condition\SkuGroup, and others. The whole condition tree was stored, as Magento has always stored these, in catalog_rule.conditions_serialized as a PHP serialize() blob.

Here is roughly what one of those rows looked like, decoded:

// 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":...;
  }
}

On every Magento cart load, that blob was unserialized, the tree was walked, and a 38% line-item discount was applied when all three custom conditions matched. Customer in segment 'wholesale', lifetime spend above €15k, SKU prefix on a fixed list of bulk packs. It had run for eight years without an incident report. The original developer was gone. The half-page of documentation was a Confluence link that redirected to a 404.

What Shopware's Migration Assistant does with it

Shopware's Migration Assistant maps Magento entities to Shopware ones. Customers go to customer. Products go to product. Magento price rules go to the Shopware promotion entity, which has its own Rule Builder. Where Magento stored a PHP-serialized tree, Shopware stores a JSON tree composed of AndRule, OrRule, LineItemOfTypeRule, and friends.

For the standard Magento condition classes the converter has explicit mappers. For unknown classes, like our Acme\QuoteRules\Condition\* set, it falls back. The shape of the fallback, simplified from the 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
}

An empty AndRule in Shopware evaluates to true. A promotion with a true rule matches every cart. A 38% line-item discount applied universally. The migration log carried one warning line per affected rule, 47 in total. Nobody read those lines because the migration finished with a success exit code and the first round of test orders looked fine.

Takeaway

A migration tool's success code means the import finished. It does not mean the business logic survived. The semantics of a serialized PHP blob are not the migrator's responsibility. They are yours.

Why staging passed for eight days

This is the part the team kept replaying. Their staging QA matrix was four years old. It tested three scenarios: a logged-in wholesale account placing a bulk order, a logged-in retail account placing a single item, and a logged-in retail account abandoning a cart. All three accounts sat in customer groups that the migrator had mapped correctly. So every QA order was either a customer where the 38% discount happened to be expected, or a product outside the SKU prefix list where the original rule had quietly failed an inner check, or a wholesale account where a higher-precedence rule overrode the broken one.

The matrix never tested the most common state on a live store: anonymous browsing. Nobody had thought to write a 'logged-out visitor buys a napkin' test, because in eight years of Magento that case was uninteresting. After the migration it was the only case that mattered, and it was the one case nobody ran.

The migration log made the same mistake easy to repeat. The Shopware import had thrown 230 warnings on the customer-attribute pass alone, all benign, all expected because of how Magento's open-ended customer_entity_varchar attributes map onto Shopware's typed schema. The team had learned by week two to skim past the warning channel. By week three, anything yellow was background. The 47 lines that actually mattered were sandwiched between rows that genuinely did not.

Eleven days of debugging the wrong layer

The first three days went to Shopware itself. They assumed a misconfigured promotion in the imported set, opened each one in the admin, and saw what looked like reasonable rules. Customer group, sales channel, validity window. Nothing said 'always true'. The admin UI does not render an empty AndRule as 'matches everything'. It renders it as a blank condition group, which the team read as 'the condition is whatever I configure here', not 'the condition is already satisfied for every cart in your store'.

Days four through seven went to the Rule Builder cache. They cleared, rebuilt, restarted, re-imported a subset. The bug stayed exactly where it was. They opened a support ticket. They wrote a custom logger. They diffed the promotion JSON against a hand-built reference rule. The diff was clean. The reference rule did not match the napkin cart. The imported rule did.

Somewhere in there they spent half a day on the Shopware tax engine. The 38% number was suspiciously close to the inverse of their 19% Dutch VAT applied twice, so the lead developer chased a rounding ghost through the net-to-gross pipeline before realising the discount was a line-item delta, not a tax artifact. The trace was clean. The hypothesis was wrong.

Day eight, the only developer who had read the converter source noticed the warning lines in the migration log. There were 47 of them, one per imported promotion that referenced an Acme class. He pulled the source, traced the fallback, and the room went quiet. The fix was now a scoping conversation, not a debugging one.

The fix that actually worked

Two options were on the table. Build a proper converter extension that knew how to translate Acme\QuoteRules classes into Shopware Rule Builder nodes, or rebuild the surviving rules by hand in the Shopware admin. They tried the converter route first.

The extension had to do two things. Read the serialized blob without instantiating the missing Acme classes (a controlled parse, not a raw unserialize), and translate the resulting tree into Shopware's Rule JSON. The first half worked. The second half ran into a class of conditions that the Rule Builder cannot express. Acme\AnnualSpend hit a stored procedure on every cart evaluation; the closest Shopware equivalent is a custom Rule shipped as a plugin, evaluated at cart-load time, with its own caching layer to avoid hammering the database on every page render. That is a real piece of engineering work, not a one-day port. Acme\SkuGroup read its allowed prefixes out of a config XML that had been edited by three people over six years and was not under version control for the first four.

So they did the boring thing. They printed the 47 rules from a one-shot 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;

They walked the export with the head of sales, who turned out to be the only person who knew the business intent of each rule. Most were dead. The SKU prefix list referenced products discontinued in 2021. The CustomerSegment values pointed at segments that had been collapsed in a 2023 reorg. Twelve rules survived the conversation. They rebuilt those twelve by hand in the Shopware Rule Builder, with screenshots of every rule pinned to a shared doc so the institutional memory finally lived somewhere readable. The other 35 rules were deleted from the Shopware import. Day eleven ended with a clean test pass on anonymous, B2C, and B2B carts. Go-live was four working days later.

What to actually check before you start

If you are sitting on a Magento store older than four years and a Shopware migration on your roadmap, the audit is short and worth running today.

Open your Magento database. Run this against catalog_rule. Any non-zero result means a custom or third-party module has written its own condition classes into the rule engine, and the Migration Assistant will not handle them correctly:

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

Then do the same against salesrule, which has the same shape and the same problem. The action blob matters as much as the condition blob, because Magento lets a cart-price rule carry a custom action class too, and the migrator will quietly drop those:

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

Then list every non-Magento, non-Mage namespace under app/code. Each one is a module that may have hooked the rule engine, the checkout, or the customer save pipeline, and each one is a separate question for the migration plan:

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

Then grep every events.xml in those modules for the three observer points that quietly rewrite carts and orders: checkout_cart_save_after, sales_order_place_after, catalog_product_save_after. Anything that fires there is business logic that the Migration Assistant does not see and cannot port. You have to port it by hand or budget the rebuild.

If any of those checks turns up something, write the converter extension where you can, or budget two days of sales-and-engineering time to rebuild the rules by hand in Shopware. Do not assume the migration warning log is read. It will not be.

The wider pattern

A piece doing the rounds last week argued that the only scalable DELETE in Postgres is DROP TABLE. The framing is narrow, but the broader idea is the one this team relearned the hard way. Databases accrete logic that nobody documented and nobody owns. A serialized PHP blob in catalog_rule is not a row of data. It is a piece of executable business policy from 2018 whose author has moved to Berlin and whose job title was 'freelance Magento dev'. On the day you migrate it, you are responsible for what it does, regardless of whether you knew it existed.

When we run a migration off Magento for a client, the first day is forensic, not architectural. We grep conditions_serialized. We list every namespace under app/code. We read the changelog of every observer attached to checkout_cart_save_after, sales_order_place_after, and catalog_product_save_after. We run an anonymous-cart test on staging on day three, not day eleven, because the cheapest hour of a migration project is the one you spend before you start.

When we ran a similar audit for a Dutch B2B wholesaler last spring, the surprise was not the custom price rules. It was a shipping-method observer that bypassed the rule engine entirely and patched the cart total during checkout_cart_save_after. The fix was straightforward once we knew where to look. We do this kind of legacy migration work often, and the pattern is always the same shape: the rule that broke production was written by someone who is no longer in the room.

The five-minute audit

Open your Magento DB. Run the two SQL queries above. List the non-Magento namespaces under app/code. If any of the three checks comes back with results, do not start your Shopware migration without a converter plan or a rebuild plan. That is the whole brief.

Key takeaway

A migration tool's success code means the import finished. It does not mean the business logic survived. Serialized PHP blobs are your responsibility, not the migrator's.

FAQ

Does Shopware's Migration Assistant convert custom Magento catalog rule conditions?

No. It maps the standard Magento condition classes but falls back to an empty Shopware AndRule when it hits a custom or third-party condition class. An empty AndRule evaluates to true, which means every cart matches.

How do I check if my Magento store has custom catalog rule conditions?

Run SELECT COUNT(*) FROM catalog_rule WHERE conditions_serialized NOT LIKE '%Magento\\%'. Any non-zero result means a custom class is in play. Do the same against the salesrule table and check the actions_serialized column too.

Does the Migration Assistant log this fallback anywhere?

Yes, as a warning line per affected rule in the migration runtime log. The migration still exits with a success code, so the warnings are rarely read until something breaks in production.

Is it safer to write a converter extension or rebuild the rules by hand?

If the original business intent is documented and the custom classes are simple, write the converter. If documentation is gone and the rule count is small (under fifty), rebuild by hand with the business owner in the room.

magentomigrationphplegacy sitese-commercecase study

Building something?

Start a project