<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yuriy Tkachenko</title>
    <description>The latest articles on DEV Community by Yuriy Tkachenko (@yuriydne).</description>
    <link>https://dev.to/yuriydne</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4060848%2F84cf7a51-85f0-411a-b665-cbe3bb46137d.jpeg</url>
      <title>DEV Community: Yuriy Tkachenko</title>
      <link>https://dev.to/yuriydne</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yuriydne"/>
    <language>en</language>
    <item>
      <title>One locale code silently killed 92% of a Magento Shopware migration — and the tool reported Done</title>
      <dc:creator>Yuriy Tkachenko</dc:creator>
      <pubDate>Mon, 03 Aug 2026 14:40:21 +0000</pubDate>
      <link>https://dev.to/yuriydne/one-locale-code-silently-killed-92-of-a-magento-shopware-migration-and-the-tool-reported-done-4l6j</link>
      <guid>https://dev.to/yuriydne/one-locale-code-silently-killed-92-of-a-magento-shopware-migration-and-the-tool-reported-done-4l6j</guid>
      <description>&lt;p&gt;&lt;strong&gt;13,154 orders in. 1 order out.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkh0oqfp9ssuoi0wmu5nm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkh0oqfp9ssuoi0wmu5nm.png" alt=" " width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What broke.&lt;/strong&gt; Magento stores some locales with a BCP-47 script subtag (&lt;code&gt;zh_Hans_CN&lt;/code&gt;, &lt;code&gt;zh_Hant_TW&lt;/code&gt;). The converter turns a Magento locale into a Shopware one with &lt;code&gt;str_replace('_', '-', …)&lt;/code&gt;, emitting &lt;code&gt;zh-Hans-CN&lt;/code&gt;. Shopware's &lt;code&gt;locale&lt;/code&gt; table has no such row — it uses &lt;code&gt;zh-CN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why it cascades.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who is affected.&lt;/strong&gt; Any Magento store with Chinese store views, and any locale carrying a script subtag — &lt;code&gt;sr_Latn_*&lt;/code&gt;, &lt;code&gt;az_Latn_*&lt;/code&gt;, &lt;code&gt;uz_Latn_*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How to avoid it.&lt;/strong&gt; Patch the fallback in the converter before the run, or pre-create locale rows under the exact codes the converter emits. Creating the &lt;em&gt;correct&lt;/em&gt; Shopware languages in advance does not help — I tried that first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reported.&lt;/strong&gt; Issue and PR: &lt;a href="https://github.com/shopware/shopware/issues/18901" rel="noopener noreferrer"&gt;#18901&lt;/a&gt;, &lt;a href="https://github.com/shopware/SwagMigrationMagento/pull/61" rel="noopener noreferrer"&gt;#61&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Target: Shopware 6.7.2.2 on &lt;code&gt;dockware/dev:6.7.2.2&lt;/code&gt;, Migration Assistant 18.0.0, plugin &lt;code&gt;SwagMigrationMagento&lt;/code&gt; 13.0.0, local gateway — direct database access, the usual choice for a large shop.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;un-patched&lt;/strong&gt;, to measure the full cascade rather than stop at the first exception. One unrelated compatibility fix (a single DBAL call, reported separately: &lt;a href="https://github.com/shopware/shopware/issues/18904" rel="noopener noreferrer"&gt;#18904&lt;/a&gt;) was applied so the readers could run at all; the migration logic itself is the shipped code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counters do not add up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Source: 13,154 orders, 6,960 customers, 7,629 products. Target after the run: &lt;code&gt;Orders (1)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log names the exact string
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;swag_migration_logging&lt;/code&gt; is the first place to look, and it was specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two languages, two sales channels, and customers missing a &lt;code&gt;languageId&lt;/code&gt;. All four exceptions carry the same code: &lt;code&gt;zh-Hans-CN&lt;/code&gt;. Shopware has no locale with that code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The converter has no fallback
&lt;/h2&gt;

&lt;p&gt;The string is built on the read side. In &lt;code&gt;src/Profile/Magento/Gateway/Local/Reader/LanguageReader.php:67&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'locale'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;\str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$storeConfig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'locale'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same replacement happens at line 83 for the default website locale. For &lt;code&gt;en_US&lt;/code&gt; or &lt;code&gt;de_DE&lt;/code&gt; this is correct. For &lt;code&gt;zh_Hans_CN&lt;/code&gt; it produces &lt;code&gt;zh-Hans-CN&lt;/code&gt;, which is a well-formed BCP-47 tag that Shopware simply does not ship.&lt;/p&gt;

&lt;p&gt;The consume side is &lt;code&gt;src/Profile/Magento/Converter/LanguageConverter.php:80&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$localeUuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;localeLookup&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;oldIdentifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$this-&amp;gt;oldIdentifier&lt;/code&gt; is the already-hyphenated code. The result is written unconditionally a few lines later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$converted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'localeId'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$localeUuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// line 108&lt;/span&gt;
&lt;span class="nv"&gt;$converted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'translationCodeId'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$localeUuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// line 109&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one lookup, no retry with a shorter code, and no guard for the case where it does not resolve. &lt;code&gt;LocaleLookup::get()&lt;/code&gt; returns &lt;code&gt;?string&lt;/code&gt; — it returns null rather than throwing — so nothing at this call site distinguishes "no such locale" from a normal miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;zh_Hans_CN&lt;/code&gt; is correct Magento, not a broken value
&lt;/h2&gt;

&lt;p&gt;It is worth being precise about who is wrong here, because it changes what you fix.&lt;/p&gt;

&lt;p&gt;Chinese needs the script subtag: &lt;code&gt;Hans&lt;/code&gt; and &lt;code&gt;Hant&lt;/code&gt; distinguish Simplified from Traditional, and the country code alone cannot. Magento stores the full tag. Shopware's locale list carries &lt;code&gt;zh-CN&lt;/code&gt; and &lt;code&gt;zh-TW&lt;/code&gt; instead. Both are defensible choices; they just do not line up, and nothing in the migration path reconciles them.&lt;/p&gt;

&lt;p&gt;The evidence that this is specifically about the script subtag: Japanese (&lt;code&gt;ja_JP&lt;/code&gt;) and Korean (&lt;code&gt;ko_KR&lt;/code&gt;) in the same shop migrated cleanly, because &lt;code&gt;ja-JP&lt;/code&gt; and &lt;code&gt;ko-KR&lt;/code&gt; exist on both sides. Only the locales with a middle segment failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing the target the obvious way does not help
&lt;/h2&gt;

&lt;p&gt;Before the run I created &lt;code&gt;zh-CN&lt;/code&gt; and &lt;code&gt;zh-TW&lt;/code&gt; in the target — the correct Shopware codes for those store views. The tool still looked up &lt;code&gt;zh-Hans-CN&lt;/code&gt; and &lt;code&gt;zh-Hant-TW&lt;/code&gt;, and still failed.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One language failure becomes two missing websites
&lt;/h2&gt;

&lt;p&gt;The same exception fires during &lt;code&gt;sales_channel&lt;/code&gt; conversion, and that is where the blast radius changes shape. A Shopware sales channel cannot be created without a resolvable default language.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Magento website&lt;/th&gt;
&lt;th&gt;Store views&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Website A (7 languages)&lt;/td&gt;
&lt;td&gt;includes Chinese&lt;/td&gt;
&lt;td&gt;mapping row written, &lt;code&gt;entity_id&lt;/code&gt; NULL — no channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website B (5 languages)&lt;/td&gt;
&lt;td&gt;includes Chinese&lt;/td&gt;
&lt;td&gt;mapping row written, &lt;code&gt;entity_id&lt;/code&gt; NULL — no channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website C (English only)&lt;/td&gt;
&lt;td&gt;no Chinese&lt;/td&gt;
&lt;td&gt;channel created&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of three brands produced no sales channel at all. The one that survived is the smallest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhtrxo15hdadv8ssqh9yh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhtrxo15hdadv8ssqh9yh.png" alt=" " width="797" height="134"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the foreign keys finish the job
&lt;/h2&gt;

&lt;p&gt;With the channels absent, Writing failed on the constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customer.sales_channel_id -&amp;gt; sales_channel  (FK 1452)

orders      13,154 source -&amp;gt;     1 written -&amp;gt;  13,153 failed
customers    6,960 source -&amp;gt;     8 written -&amp;gt;   6,952 failed
products     7,629 source -&amp;gt; 2,313 written -&amp;gt;   5,316 failed
product_visibility: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.)&lt;/p&gt;

&lt;p&gt;Source against target, per entity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;migrated&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;orders&lt;/td&gt;
&lt;td&gt;13,154&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.008%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;customers&lt;/td&gt;
&lt;td&gt;6,960&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;products&lt;/td&gt;
&lt;td&gt;7,629&lt;/td&gt;
&lt;td&gt;2,313&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;categories&lt;/td&gt;
&lt;td&gt;804&lt;/td&gt;
&lt;td&gt;767&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;newsletter recipients&lt;/td&gt;
&lt;td&gt;112&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap between "fixable" and "fixed" is developer work
&lt;/h2&gt;

&lt;p&gt;At the Error resolution step the tool classified 231,162 SEO-URL errors as &lt;code&gt;user_fixable = 1&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The fix dialog pages through records 25 at a time. That is the whole mechanism the interface offers for 240,687 rows.&lt;/p&gt;

&lt;p&gt;There is another route. &lt;code&gt;swag_migration_fix&lt;/code&gt; is an ordinary DAL entity, so &lt;code&gt;/api/_action/sync&lt;/code&gt; 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 (&lt;code&gt;zh_Hans&lt;/code&gt; store views → &lt;code&gt;zh-CN&lt;/code&gt;: 113,917 rows; &lt;code&gt;zh_Hant&lt;/code&gt; store views → &lt;code&gt;zh-TW&lt;/code&gt;: 117,245 rows). That is precisely the mapping the converter should have produced.&lt;/p&gt;

&lt;p&gt;The shop still did not migrate.&lt;/p&gt;

&lt;p&gt;The important number in this section is not 240,687. It is zero: the root cause has no fix row at all. &lt;code&gt;customer.sales_channel_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The working fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option A — patch the call site.&lt;/strong&gt; Try the full code, then the code without the script subtag, then the language-only code, and take the first that resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$localeUuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocaleFallback&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;oldIdentifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$localeUuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;localeLookup&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$localeUuid&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LocaleFallback::candidates('zh-Hans-CN')&lt;/code&gt; returns &lt;code&gt;['zh-Hans-CN', 'zh-CN', 'zh']&lt;/code&gt;. Everything downstream is unchanged; &lt;code&gt;localeId&lt;/code&gt; now receives a resolvable id instead of null. The candidate logic is a pure class, so it unit-tests without a Shopware bootstrap. PR: &lt;a href="https://github.com/shopware/SwagMigrationMagento/pull/61" rel="noopener noreferrer"&gt;#61&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I put the fallback at the language call site rather than in &lt;code&gt;LanguageReader&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — no patch.&lt;/strong&gt; Pre-create locale rows in the target under the exact codes the converter emits (&lt;code&gt;zh-Hans-CN&lt;/code&gt;, &lt;code&gt;zh-Hant-TW&lt;/code&gt;), before creating the migration connection. Less clean, but it needs no plugin change. Details in the report: &lt;a href="https://github.com/shopware/shopware/issues/18901" rel="noopener noreferrer"&gt;#18901&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should check for this
&lt;/h2&gt;

&lt;p&gt;Any Magento store selling into Chinese-speaking markets, and any store with a script-subtag locale: &lt;code&gt;sr_Latn_*&lt;/code&gt;, &lt;code&gt;az_Latn_*&lt;/code&gt;, &lt;code&gt;uz_Latn_*&lt;/code&gt; and their relatives.&lt;/p&gt;

&lt;p&gt;The check takes one query against the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;core_config_data&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'general/locale/code'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any value with three underscore-separated segments is a blocker, not a detail.&lt;/p&gt;

&lt;p&gt;One caveat on that query, learned elsewhere in this lab: Magento writes a &lt;code&gt;core_config_data&lt;/code&gt; row only when a setting is &lt;em&gt;changed&lt;/em&gt;. 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 &lt;code&gt;store&lt;/code&gt; and &lt;code&gt;store_website&lt;/code&gt; as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method note
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist for a multi-language migration
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pull the store-view locale list before quoting. A script subtag is a blocker, not a detail.&lt;/li&gt;
&lt;li&gt;Create everything the source needs — currencies, tax rates, languages — in the target &lt;em&gt;before&lt;/em&gt; creating the migration connection. The premapping cannot offer an option that does not exist.&lt;/li&gt;
&lt;li&gt;Pre-creating the &lt;em&gt;correct&lt;/em&gt; language codes does not help. What must match is the code the converter emits.&lt;/li&gt;
&lt;li&gt;Size the job as products × store views, not products. A thirteen-store-view shop reads an order of magnitude more than its catalogue suggests.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;I do pre-migration audits and Magento-side subcontracting for agencies running Magento → Shopware projects.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/yuriy-tkachenko-19553137/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; — &lt;a href="https://github.com/yuriyDne/magento-shopware-migration-toolkit" rel="noopener noreferrer"&gt;Migration toolkit (GitHub)&lt;/a&gt;&lt;/p&gt;

</description>
      <category>shopware</category>
      <category>php</category>
      <category>ecommerce</category>
      <category>magento</category>
    </item>
  </channel>
</rss>
