← Blog

Magento

Magento to Shopware migration: the 11-day EAN-13 stall

On day three the head of catalog scrolled to a product page and saw a JSON object where the product name should have been. 47,200 SKUs. Same bug. Eleven days to find it.

Jacob Molkenboer· Founder · A Brand New Company· 14 Jun 2026· 9 min
Open leather shipping logbook on ivory paper with brass freight tag, linen twine, green ribbon, red wax seal.

On day three Anouk opened a product page in the Shopware staging shop and stopped scrolling. The product name field read {"value":"8714026034817","scope":"global"}. She refreshed. Same string. She picked a different SKU at random. Same shape, different EAN. She picked a third. Same.

The migration was supposed to ship that Friday. We were eight engineers on the ABN side, ten on the Rotterdam client side, and we had just discovered that 47,200 product names in Shopware were JSON objects pretending to be names. Nobody had touched the name field. Nothing in the migration plan mapped name from anywhere strange. And yet there it was, repeating, predictable, with the EAN-13 of each SKU staring back through the structure.

This is the post-mortem. It took eleven days to find, two hours to fix, and the rest of the year to stop pretending it could not happen again.

The catalog in Magento before we touched it

The shop was a Rotterdam B2B distributor running Magento 2.4.6 on PHP 8.2, around 6,800 parent products and 47,200 SKUs once you counted configurables and their children. The catalog had been migrated once before, from Magento 1.9 in 2019, and had collected the usual sediment: orphaned EAV attributes, a custom ean_13 attribute on every product, three different product-type definitions for what should have been one, and a PIM that wrote to Magento overnight.

The PIM ran an Akeneo-style flow. A community-maintained connector polled the PIM API every six hours, took the product payload, and pushed it into Magento through the REST API. The version pinned in composer.json had not been updated since 2022.

The Shopware target was 6.6 LTS on PHP 8.3. We used Shopware's Migration Assistant as the spine, with a custom plugin overriding the product converter so the client's specific attribute mapping (manufacturer SKU, EAN-13, customs codes, energy-label PDFs) landed in the right Shopware fields. The custom converter ran clean against a 200-product test export. We greenlit the full pull on a Tuesday night.

The first sign something was off

Wednesday morning the import was done. The Shopware admin showed the right product count. The category tree looked right. Stock numbers matched. We marked it green and handed staging to the client's catalog team for sign-off.

Anouk was their head of catalog. She opened the storefront, picked a product the way a customer would, and instead of Rolgordijn verduisterend 120x180 antraciet she saw a JSON object. By 10:14 she had a Slack thread open with screenshots from four products. By 11:00 we had confirmed it was every product. By 12:30 we had argued ourselves into three wrong theories about why.

Three wrong theories before the right one

The wrong theories were all reasonable and all expensive.

Theory one: the converter overwrote name from a custom attribute. We re-read the converter line by line. The name mapping was the default Shopware mapping. It pulled from catalog_product_entity_varchar where attribute_id matched name. Nothing exotic. We added log lines, re-ran a 50-product test, and the names came through clean. So the converter was not the bug.

Theory two: a post-import job rewrote names from EAN. Someone on the client side had once written a script to backfill names from PIM exports. We grepped for it, found it, confirmed it was disabled in the cron table, ran the import again with the cron daemon stopped entirely. Same JSON in the name field. So the post-import job was not the bug.

Theory three: Shopware's Migration Assistant had a bug. We opened a ticket. The Shopware team came back inside a day asking, politely, whether we had checked our source data. We bristled. Then we checked our source data.

The query that ended the argument

The Magento name attribute lives in catalog_product_entity_varchar. The row we expected to see was a Dutch product name in clean UTF-8. The row we actually saw was this:

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\"}"          |
+-----------+--------------------------------------------------------------+

The name attribute in Magento was a JSON-encoded JSON object whose only meaningful field was an EAN-13. The Migration Assistant had decoded it once on the way out, written the decoded inner string as the product name, and gone on with its day. The bug was upstream, in the data we had imported from PIM weeks before we ever touched Shopware.

The double-wrap inside the PIM connector

The connector's AttributeMapper normalised every value into a wrapper shape before pushing it to Magento:

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

The PIM exported the EAN-13 in two places. The first export came from the product attribute itself, with the raw value 8714026034817. That value went through pack() once, became a JSON string, and was pushed to Magento as the ean_13 attribute. Correct, if ugly.

The second export came from a custom "name override" rule the client had set up two years earlier. The rule said: if the product has no Dutch translation in the PIM, fall back to the EAN. Whoever wrote that rule did not realise the fallback was reading the already-packed value from a transient cache, not the raw EAN. So the connector took {"value":"8714026034817","scope":"global"} from cache, ran it through pack() a second time, and pushed the double-encoded string into Magento's name field.

For two years nobody noticed. Magento's storefront displayed the product name from the Dutch store view, which had a proper translation, and the global-scope name was never rendered. The EAN export to Google Shopping pulled from ean_13, not name. The fallback never had to fall back.

Then we migrated to Shopware, which reads the global-scope name first.

Warning

Migration tools are only as honest as the data they read. A field that has been silently broken in production for years can ship its silence straight into the new system, where the surface that was hiding it no longer exists.

The audit that found 47,200 rows

Once we knew what we were looking for, the audit was four hours of SQL and a coffee. We wrote a one-shot query that flagged every varchar attribute value whose contents started with { and parsed as JSON. We ran it against every 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;

The result: 47,200 rows in name, all on the global store view. Zero rows on the Dutch view, zero on the English, zero on the German. The PIM had only ever written to global. The translation editors had been quietly fixing the symptom for two years without knowing the wound.

The rollback and the transform

We had two options. Fix the Magento source before re-running the migration, or fix it inside the converter on the way into Shopware. We chose the converter, because the client did not want to redeploy Magento code on a production system they were two weeks away from retiring.

The converter override added a guard. Before writing the name field, parse it. If it is valid JSON with a value key, take the value. If that value is still JSON, parse again. Walk the layers until the result is a plain string, then check whether the result looks like an EAN-13 (13 digits, valid GS1 checksum per the EAN-13 spec). If it does, fall back to the localised name from the Dutch store view. If the Dutch name is also missing, fall back to "SKU " + sku and log the row.

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 capped the unwrap loop at three layers as a safety on top of the safety. Anything deeper than that was a sign of a different bug, and we wanted to fail loudly rather than recurse forever.

The re-import took six hours. Anouk signed off the next morning. The migration shipped 14 days behind the original date, 11 of which were the bug and 3 of which were the catch-up we ran on parallel work that had been blocked behind sign-off.

What we changed in our migration playbook

The eleven days were not really about EAN-13. They were about how much trust we put in a 200-product sample. 200 products had passed because the bug only existed on the global store view, and our sample had been drawn from the Dutch store view (where the catalog team did their daily work and where the test data looked cleanest). A pure sample bias.

We now sample three ways for every Magento migration:

  • 200 products from the most-active store view, the way we always have.
  • 200 products from every other store view, including the global view, even when the client tells us "nobody uses global".
  • 50 products selected by SQL for anomalies: values starting with {, values longer than 2x the median for their column, values that are exact duplicates across 100+ rows.

The third bucket is the one that would have caught this in an afternoon. We also run JSON_VALID across every varchar and text attribute and surface any column where more than 0.1% of rows parse as JSON. Magento does not store JSON in EAV by design. If JSON is there, something upstream put it there on purpose, and we want to know what.

When we built the Shopware 6 cutover for this Rotterdam client, the thing we ran into was a two-year-old PIM bug we could not have predicted from the brief. We ended up solving it with a converter-level unwrap and a sampling rule we now apply to every legacy migration we take on. The cost of that rule is half a day per project. The cost of not having it was eleven.

If you are sitting on a Magento catalog you plan to migrate this year, the smallest useful thing you can do today is run SELECT COUNT(*) FROM catalog_product_entity_varchar WHERE value LIKE '{%' AND JSON_VALID(value) = 1; against your production database. If it returns anything other than zero, you have a conversation to start with whoever wrote your PIM connector.

Key takeaway

Sample your migration from every store view, not just the busy one. The bug in global scope ships the moment your new system reads global first.

FAQ

How does double serialisation end up inside a Magento attribute?

A connector wraps a value in JSON for transport, then a fallback rule reads the already-wrapped value from cache and wraps it again. Magento's EAV stores the result as a string because the attribute is varchar.

Why did the storefront not catch the broken name for two years?

The bad value lived on the global store view. Every localised store view rendered its own translated name, so the symptom only appeared in fields that read global as the default, which the storefront never did.

Is Shopware's Migration Assistant the wrong tool for big Magento catalogs?

No. The tool did exactly what it was told. Our converter mapped name from the Magento global-scope attribute and Shopware wrote what it found. The fix lives in the converter and in a source-data audit, not in the assistant.

What is the fastest pre-migration check for this class of bug?

Run JSON_VALID across every varchar and text attribute. Any column where more than a fraction of a percent of rows parse as JSON is a column where upstream code wrote something Magento was not designed to hold.

Should we fix the source data or fix the converter on the way out?

Fix the converter when the source system is days from retirement and a redeploy is expensive. Fix the source when the broken data will still be read by other consumers after the migration ships.

magentomigrationlegacy sitese-commercearchitecturecase study

Building something?

Start a project