13,154 orders in. 1 order out.
The source was an anonymised copy of a real Magento CE store: 3 websites, 13 store views, 7 languages including Simplified and Traditional Chinese, 7,629 products, 6,960 customers. Shopware's Migration Assistant ran every step green and finished with "The Migration is done". The target database held one order, eight customers and 2,313 products. Orders took the worst hit — one of 13,154 survived; across all entities, roughly 92% of the shop failed to arrive. The cause was a single string.
TL;DR
-
What broke. Magento stores some locales with a BCP-47 script subtag (
zh_Hans_CN,zh_Hant_TW). The converter turns a Magento locale into a Shopware one withstr_replace('_', '-', …), emittingzh-Hans-CN. Shopware'slocaletable has no such row — it useszh-CN. - Why it cascades. The lookup fails with no fallback, so the language is not created. A sales channel needs a resolvable default language, so two of the three websites got no channel. Everything scoped to those channels then failed a foreign key at Writing.
-
Who is affected. Any Magento store with Chinese store views, and any locale carrying a script subtag —
sr_Latn_*,az_Latn_*,uz_Latn_*. - How to avoid it. Patch the fallback in the converter before the run, or pre-create locale rows under the exact codes the converter emits. Creating the correct Shopware languages in advance does not help — I tried that first.
- Reported. Issue and PR: #18901, #61.
Setup
The source is an anonymised copy of a real commercial Magento 2 CE shop. Customers, addresses, orders, quotes and newsletter data were replaced with fake values that preserve structure and row counts, so the migration reads realistic volumes without touching personal data. Nothing identifying the client appears here.
Target: Shopware 6.7.2.2 on dockware/dev:6.7.2.2, Migration Assistant 18.0.0, plugin SwagMigrationMagento 13.0.0, local gateway — direct database access, the usual choice for a large shop.
The target was prepared before the run: nine languages created, US tax rates in place, premapping filled in through the API. The run deliberately went through un-patched, to measure the full cascade rather than stop at the first exception. One unrelated compatibility fix (a single DBAL call, reported separately: #18904) was applied so the readers could run at all; the migration logic itself is the shipped code.
The counters do not add up
I did not start from the interface. After a Done screen I count rows, because the Done screen is a statement about the process, not about the data.
Source: 13,154 orders, 6,960 customers, 7,629 products. Target after the run: Orders (1).
That is not a partial migration. A partial migration loses a slice. This lost almost everything while reporting success, which means the failure happened somewhere structural — above the level of individual records.
The log names the exact string
swag_migration_logging is the first place to look, and it was specific:
language RUN_EXCEPTION Locale entity for code "zh-Hans-CN" not found
language RUN_EXCEPTION Locale entity for code "zh-Hant-TW" not found
sales_channel RUN_EXCEPTION Locale entity for code "zh-Hans-CN" not found
sales_channel RUN_EXCEPTION Locale entity for code "zh-Hant-TW" not found
customer REQUIRED_FIELD_MISSING languageId
Two languages, two sales channels, and customers missing a languageId. All four exceptions carry the same code: zh-Hans-CN. Shopware has no locale with that code.
The converter has no fallback
The string is built on the read side. In src/Profile/Magento/Gateway/Local/Reader/LanguageReader.php:67:
'locale' => \str_replace('_', '-', $storeConfig['locale']),
The same replacement happens at line 83 for the default website locale. For en_US or de_DE this is correct. For zh_Hans_CN it produces zh-Hans-CN, which is a well-formed BCP-47 tag that Shopware simply does not ship.
The consume side is src/Profile/Magento/Converter/LanguageConverter.php:80:
$localeUuid = $this->localeLookup->get($this->oldIdentifier, $this->context);
$this->oldIdentifier is the already-hyphenated code. The result is written unconditionally a few lines later:
$converted['localeId'] = $localeUuid; // line 108
$converted['translationCodeId'] = $localeUuid; // line 109
There is one lookup, no retry with a shorter code, and no guard for the case where it does not resolve. LocaleLookup::get() returns ?string — it returns null rather than throwing — so nothing at this call site distinguishes "no such locale" from a normal miss.
zh_Hans_CN is correct Magento, not a broken value
It is worth being precise about who is wrong here, because it changes what you fix.
Chinese needs the script subtag: Hans and Hant distinguish Simplified from Traditional, and the country code alone cannot. Magento stores the full tag. Shopware's locale list carries zh-CN and zh-TW instead. Both are defensible choices; they just do not line up, and nothing in the migration path reconciles them.
The evidence that this is specifically about the script subtag: Japanese (ja_JP) and Korean (ko_KR) in the same shop migrated cleanly, because ja-JP and ko-KR exist on both sides. Only the locales with a middle segment failed.
Preparing the target the obvious way does not help
Before the run I created zh-CN and zh-TW in the target — the correct Shopware codes for those store views. The tool still looked up zh-Hans-CN and zh-Hant-TW, and still failed.
This is the part that costs a project a day. The preparation looks right, the target shows the languages, and the migration behaves as if they were never created. What has to match is not the correct code. It is the exact code the converter emits.
One language failure becomes two missing websites
The same exception fires during sales_channel conversion, and that is where the blast radius changes shape. A Shopware sales channel cannot be created without a resolvable default language.
| Magento website | Store views | Result |
|---|---|---|
| Website A (7 languages) | includes Chinese | mapping row written, entity_id NULL — no channel |
| Website B (5 languages) | includes Chinese | mapping row written, entity_id NULL — no channel |
| Website C (English only) | no Chinese | channel created |
Two of three brands produced no sales channel at all. The one that survived is the smallest.
Then the foreign keys finish the job
With the channels absent, Writing failed on the constraint:
customer.sales_channel_id -> sales_channel (FK 1452)
orders 13,154 source -> 1 written -> 13,153 failed
customers 6,960 source -> 8 written -> 6,952 failed
products 7,629 source -> 2,313 written -> 5,316 failed
product_visibility: 0
The 2,313 products that landed are the ones scoped to Website C. (The run's own write-exception counters read a few rows higher than source minus written; I use source minus written throughout, because both sides of that subtraction are directly counted.)
Source against target, per entity:
| source | migrated | ||
|---|---|---|---|
| orders | 13,154 | 1 | 0.008% |
| customers | 6,960 | 8 | 0.1% |
| products | 7,629 | 2,313 | 30% |
| categories | 804 | 767 | 95% |
| newsletter recipients | 112 | 76 | 68% |
The arithmetic behind the headline: across the three main entities the source holds 13,154 + 6,960 + 7,629 = 27,743 records, and 1 + 8 + 2,313 = 2,322 arrived. That is 8.4% in and 91.6% missing.
Categories are global in Magento, so most of them came through — which is exactly why a category count is a bad health check. Everything scoped to a channel did not.
One more number from this run, useful when sizing a quote: the read phase processed 801,490 datasets for a catalogue of 7,629 products. Read volume scales with store views, not with catalogue size — every product is read once per store view, and this shop has thirteen.
And at the end of this run, the Migration Assistant showed seven green check marks and "The Migration is done". Every number in the table above sat behind that screen.
The gap between "fixable" and "fixed" is developer work
At the Error resolution step the tool classified 231,162 SEO-URL errors as user_fixable = 1, out of 240,687 fixable errors in total. That classification is honest: each of those rows really can be corrected by choosing the right value.
The fix dialog pages through records 25 at a time. That is the whole mechanism the interface offers for 240,687 rows.
There is another route. swag_migration_fix is an ordinary DAL entity, so /api/_action/sync accepts it like any other bulk write. I cleared all 240,687 in about a minute with a small API client — each SEO URL given the correct language for its store view (zh_Hans store views → zh-CN: 113,917 rows; zh_Hant store views → zh-TW: 117,245 rows). That is precisely the mapping the converter should have produced.
The shop still did not migrate.
The important number in this section is not 240,687. It is zero: the root cause has no fix row at all. customer.sales_channel_id is not a user-fixable field, and there would be nothing to point it at — the channel does not exist. The SEO URLs I corrected now reference channels that were never created. The interface offered a quarter of a million corrections for the symptom and none for the cause.
The working fix
Option A — patch the call site. Try the full code, then the code without the script subtag, then the language-only code, and take the first that resolves:
$localeUuid = null;
foreach (LocaleFallback::candidates($this->oldIdentifier) as $candidate) {
$localeUuid = $this->localeLookup->get($candidate, $this->context);
if ($localeUuid !== null) {
break;
}
}
LocaleFallback::candidates('zh-Hans-CN') returns ['zh-Hans-CN', 'zh-CN', 'zh']. Everything downstream is unchanged; localeId now receives a resolvable id instead of null. The candidate logic is a pure class, so it unit-tests without a Shopware bootstrap. PR: #61.
I put the fallback at the language call site rather than in LanguageReader. Normalising there would fix every consumer at once, but the locale string is also the mapping key, so changing it moves identifiers across the whole plugin. Wrong trade for a minimal patch.
Option B — no patch. Pre-create locale rows in the target under the exact codes the converter emits (zh-Hans-CN, zh-Hant-TW), before creating the migration connection. Less clean, but it needs no plugin change. Details in the report: #18901.
Stated honestly: cause, cascade and counts are measured. The patch has a red-then-green unit test and a mutation check, but an end-to-end run with it applied has not been done — this run went through un-patched on purpose.
Who should check for this
Any Magento store selling into Chinese-speaking markets, and any store with a script-subtag locale: sr_Latn_*, az_Latn_*, uz_Latn_* and their relatives.
The check takes one query against the source:
SELECT DISTINCT value FROM core_config_data WHERE path = 'general/locale/code';
Any value with three underscore-separated segments is a blocker, not a detail.
One caveat on that query, learned elsewhere in this lab: Magento writes a core_config_data row only when a setting is changed. A store view left at its default locale has no row. An empty or short result does not prove the shop is single-language — confirm against store and store_website as well.
What actually works
An article that only lists defects is a complaint. The catalogue side of this tool is good, and saying so is what makes the rest credible.
Nested categories resolve with their descriptions and formatting intact. Configurable products become real Shopware variants — the parent renders a working option selector, and choosing a variant switches to that variant's own product number. Magento attributes arrive as working storefront filters, in category navigation and in search. Prices and discounts render correctly, including the struck-through original with a percentage badge. Product and category meta data migrated field for field, exactly.
The pattern across this lab: the catalogue migrates well, and the further you get from the catalogue — orders, media, URLs, consent, tax — the more is lost without a word.
Method note
Every claim here is checked twice: once as a number from the database, once as a line of shipped source. Six conclusions from this lab did not survive that second check, and they failed the same way — a number from the target compared against a number from the source that counted something different (17,705 Magento URL rewrites against 2,187 distinct products; 317,638 order item rows against 173,058 Shopware line items). That failure mode is the subject of the next article in this series.
Checklist for a multi-language migration
- Pull the store-view locale list before quoting. A script subtag is a blocker, not a detail.
- Create everything the source needs — currencies, tax rates, languages — in the target before creating the migration connection. The premapping cannot offer an option that does not exist.
- Pre-creating the correct language codes does not help. What must match is the code the converter emits.
- Size the job as products × store views, not products. A thirteen-store-view shop reads an order of magnitude more than its catalogue suggests.
- Do not treat Done as acceptance. Count source against target per entity, and check the sales channel list separately — that is where whole brands disappear.
I do pre-migration audits and Magento-side subcontracting for agencies running Magento → Shopware projects.


Top comments (0)