Diagnostic SEO URLs
SEO URL not generated for a product on a shared-language sales channel
A product already ranks fine on your main sales channel, with a clean SEO path in every link. Then you assign it to a second sales channel that happens to use the same language, expecting the same thing to happen there. Instead the second channel silently gets nothing. No SEO URL is written for it, and every storefront link for that product on that channel falls back to the raw /detail/{id} route. Here is why Shopware's own uniqueness rule causes this and a script that finds every product it has already happened to.
Shopware enforces a unique index on the seo_url table across (language_id, sales_channel_id, route_name, foreign_key) through SeoUrlPersister::updateSeoUrls. A regression introduced in commit 73447962f84, confirmed as a bug from Shopware 6.6.10.6 onward in shopware/shopware#12143, causes the persister to treat a product's existing SEO URL row for one sales channel as already covering the language, and it silently skips inserting a row for a second sales channel that shares that language, or throws a uniq.seo_url.foreign_key unique constraint violation that aborts the save without cleaning up. Either way the second channel ends up with no seo_url row for that product, so its storefront links fall back to /detail/{id}. Detection code, tests, and sources are below.
The problem in plain words
A product on a normal single-storefront setup only ever needs one SEO URL per sales channel per language. Shopware writes that row once, through frontend.detail.page, and every link on the storefront uses it from then on.
The trouble starts once you run more than one sales channel in the same language, for example a main store and a B2B storefront that both serve German, or a headless channel added next to an existing one. You assign an existing product, one that already has a working SEO URL on channel A, to channel B. You expect Shopware to write a second, independent seo_url row for channel B. Instead, because the persister keys its uniqueness check on language rather than on the specific sales channel and product together, it sees a row that already exists for that language and quietly does nothing for channel B, or it collides with the unique index and the whole product save can abort partway through. Channel B's storefront link for that product then falls back to the raw, unfriendly /detail/{productId} route, and nothing in the admin UI tells you it happened.
Why it happens
The root cause sits in SeoUrlPersister::updateSeoUrls and the unique index it enforces. A few concrete ways stores end up here:
- The
seo_urltable has a unique index across(language_id, sales_channel_id, route_name, foreign_key). That is the right shape on paper, one row per language per channel per route per entity, but a regression introduced in commit73447962f84broke how the persister evaluates "does this language already have a row" before deciding whether to insert one for the new sales channel. - Confirmed as a regression from Shopware 6.6.10.6 onward in
shopware/shopware#12143, the persister short-circuits when it finds any existing SEO URL row for the product's language, even if that row belongs to a different sales channel entirely, and treats the language as already handled. - Depending on timing and whichever code path runs first, this either silently skips the insert for the second channel, or it does attempt the insert and collides with the unique index, throwing a
UniqueConstraintViolationException(uniq.seo_url.foreign_key) that can abort the whole product save without a clean rollback on the SEO side. - Stores that only ever run one sales channel per language never see this. It only surfaces once a second sales channel is added that shares a language with an existing one, for example a wholesale channel added next to the main storefront, both in English.
This is a confirmed platform bug. See shopware/shopware#12143 for the exact regression report, and the older #3246 for the related discussion of sales channel URL uniqueness not matching real storefront needs. See the citations at the end for the exact threads and docs.
This is not something you fix by writing a missing row yourself. The unique index and the buggy short-circuit in the persister are the actual defect, and it is already fixed upstream in PRs #11673 and #12098. Writing directly to seo_url for the missing channel can recreate the exact same storefront collision the index exists to prevent. The safe automated step is detection: find every product whose assigned sales channels do not each have their own seo_url row, and report it. Re-triggering generation, and only on a confirmed patched version, is the one guarded write this script will make.
The fix, as a flow
We never write to seo_url directly. The script lists every active sales channel with its language, reads each product's assigned channels from its visibilities, checks which of those channels already have an seo_url row for that product, and flags any product where an assigned channel has none while a sibling channel sharing the same language already has one. By default it only reports. Only when a human sets DRY_RUN=false, and the store is confirmed patched, does it re-save the product's own name back to itself to re-trigger SEO generation, stopping the whole batch the moment it sees the unique constraint violation again.
Build it step by step
Get an Admin API access token
Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" # start safe, change to false only on a confirmed patched version
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" // start safe, change to false only on a confirmed patched version
Authenticate and call the Admin API
A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for the sales-channel, product, and seo-url searches.
import os, requests
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_search(entity, token, body):
r = requests.post(
f"{SHOPWARE_URL}/api/search/{entity}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function apiSearch(entity, token, body) {
const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${entity} search`);
const data = await res.json();
return data.data;
}
List active sales channels and their language
Search sales-channel with active equal to true and the language association loaded, so each channel comes back with its id and languageId. This is the map we use to tell which channels actually share a language.
def fetch_active_sales_channels(token):
body = {
"filter": [{"type": "equals", "field": "active", "value": True}],
"associations": {"language": {}},
"total-count-mode": 1,
}
rows = api_search("sales-channel", token, body)
return [{"id": r["id"], "languageId": r["languageId"]} for r in rows]
async function fetchActiveSalesChannels(token) {
const body = {
filter: [{ type: "equals", field: "active", value: true }],
associations: { language: {} },
"total-count-mode": 1,
};
const rows = await apiSearch("sales-channel", token, body);
return rows.map((r) => ({ id: r.id, languageId: r.languageId }));
}
Read each product's assigned channels and existing SEO URL rows
Page through product with the visibilities association loaded to get each product's assigned salesChannelId list. For each product, search seo-url filtered by foreignKey, routeName equal to frontend.detail.page, and isDeleted equal to false, to see which channels already have a row.
def fetch_products(token, limit=200):
page = 1
out = []
while True:
body = {
"page": page,
"limit": limit,
"associations": {"visibilities": {}},
"total-count-mode": 1,
}
rows = api_search("product", token, body)
for r in rows:
visibilities = r.get("visibilities") or []
channel_ids = [v["salesChannelId"] for v in visibilities]
out.append({"id": r["id"], "productNumber": r.get("productNumber"), "assignedChannelIds": channel_ids})
if len(rows) < limit:
return out
page += 1
def fetch_seo_url_rows(token, product_id):
body = {
"filter": [
{"type": "equals", "field": "foreignKey", "value": product_id},
{"type": "equals", "field": "routeName", "value": "frontend.detail.page"},
{"type": "equals", "field": "isDeleted", "value": False},
],
"total-count-mode": 1,
}
rows = api_search("seo-url", token, body)
return [{"salesChannelId": r["salesChannelId"], "foreignKey": r["foreignKey"], "isDeleted": r["isDeleted"]} for r in rows]
async function fetchProducts(token, limit = 200) {
let page = 1;
const out = [];
while (true) {
const body = {
page,
limit,
associations: { visibilities: {} },
"total-count-mode": 1,
};
const rows = await apiSearch("product", token, body);
for (const r of rows) {
const visibilities = r.visibilities || [];
const channelIds = visibilities.map((v) => v.salesChannelId);
out.push({ id: r.id, productNumber: r.productNumber, assignedChannelIds: channelIds });
}
if (rows.length < limit) return out;
page++;
}
}
async function fetchSeoUrlRows(token, productId) {
const body = {
filter: [
{ type: "equals", field: "foreignKey", value: productId },
{ type: "equals", field: "routeName", value: "frontend.detail.page" },
{ type: "equals", field: "isDeleted", value: false },
],
"total-count-mode": 1,
};
const rows = await apiSearch("seo-url", token, body);
return rows.map((r) => ({ salesChannelId: r.salesChannelId, foreignKey: r.foreignKey, isDeleted: r.isDeleted }));
}
Decide, with one pure function
Keep the decision entirely separate from the network calls. Given one product's assigned channel ids, the known sales channels, and the seo_url rows already fetched for that product, work out which assigned channels have no row at all. This is the exact signature of #12143, a channel sharing a language with another that already has a row, silently left without its own. It takes no I/O, so it is trivial to test with plain objects.
def decide_missing_seo_urls(product, sales_channels, existing_seo_url_rows):
known_channel_ids = {sc["id"] for sc in sales_channels}
has_row = {
row["salesChannelId"]
for row in existing_seo_url_rows
if row["foreignKey"] == product["id"] and not row["isDeleted"]
}
missing_channel_ids = [
chid for chid in product["assignedChannelIds"]
if chid in known_channel_ids and chid not in has_row
]
if not missing_channel_ids:
return []
return [{"productId": product["id"], "missingChannelIds": missing_channel_ids}]
export function decideMissingSeoUrls(product, salesChannels, existingSeoUrlRows) {
const knownChannelIds = new Set(salesChannels.map((sc) => sc.id));
const hasRow = new Set(
existingSeoUrlRows
.filter((row) => row.foreignKey === product.id && !row.isDeleted)
.map((row) => row.salesChannelId)
);
const missingChannelIds = product.assignedChannelIds.filter(
(chId) => knownChannelIds.has(chId) && !hasRow.has(chId)
);
if (missingChannelIds.length === 0) return [];
return [{ productId: product.id, missingChannelIds }];
}
Report first, guard the one re-save that touches data
By default the script only prints {productId, missingChannelIds, sampleProductNumber} for every flagged product, no writes at all. If a human sets DRY_RUN=false, and only after confirming the store's Shopware version is at or beyond the fix in #11673 and #12098, the script re-saves the product's own name field back to itself with a single PATCH /api/product/{productId}, which re-triggers SeoUrlUpdater::update for every assigned channel. It stops the entire batch on the first UniqueConstraintViolationException, because that error is proof the instance is still vulnerable, and reports the rest for manual review or a version upgrade instead of continuing to write.
Never write to seo_url directly, and never flip DRY_RUN to false unless you have confirmed the store is on a Shopware version that includes #11673 and #12098. On an unpatched instance the guarded re-save can reproduce the exact uniq.seo_url.foreign_key violation this whole bug is about, so the script stops on the first sign of it rather than pushing through.
The full code
Here is the complete script in one file for each language. It authenticates, lists active sales channels, pages through every product with its visibilities, flags products missing an seo_url row on an assigned channel with the pure function, and either reports them or, only when explicitly unlocked, re-saves each one's name once, stopping the batch at the first sign the platform bug is still present.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Detect Shopware 6 products missing an SEO URL on a sales channel that shares
a language with another channel that already has one, without ever writing to
seo_url directly.
Shopware's seo_url table enforces a unique index across
(language_id, sales_channel_id, route_name, foreign_key) through
SeoUrlPersister::updateSeoUrls. A regression introduced in commit
73447962f84, confirmed from Shopware 6.6.10.6 onward in
shopware/shopware#12143, causes the persister to treat a product's existing
SEO URL row for one sales channel as already covering the language, and it
silently skips inserting a row for a second sales channel that shares that
language, or throws a UniqueConstraintViolationException
(uniq.seo_url.foreign_key) that aborts the save. Either way the second
channel ends up with no seo_url row for that product, so its storefront
links fall back to /detail/{id}.
This only ever reports affected products by default. The fix upstream is in
PRs #11673 and #12098. Only when DRY_RUN is explicitly set to false does the
script attempt a single guarded re-save of the product's own name, meant to
be used only after confirming the store runs a patched Shopware version. It
stops the whole batch on the first UniqueConstraintViolationException,
because that is proof the instance is still vulnerable. Run on demand. Safe
to run again and again in the default report-only mode.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_missing_seo_urls")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAGE_LIMIT = 200
ROUTE_NAME = "frontend.detail.page"
def decide_missing_seo_urls(product, sales_channels, existing_seo_url_rows):
"""Pure decision. No I/O. Returns [{"productId", "missingChannelIds"}] for
the given product when one or more of its assigned, known sales channels
have no non-deleted seo_url row, otherwise returns an empty list.
"""
known_channel_ids = {sc["id"] for sc in sales_channels}
has_row = {
row["salesChannelId"]
for row in existing_seo_url_rows
if row["foreignKey"] == product["id"] and not row["isDeleted"]
}
missing_channel_ids = [
chid for chid in product["assignedChannelIds"]
if chid in known_channel_ids and chid not in has_row
]
if not missing_channel_ids:
return []
return [{"productId": product["id"], "missingChannelIds": missing_channel_ids}]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_search(entity, token, body):
r = requests.post(
f"{SHOPWARE_URL}/api/search/{entity}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def fetch_active_sales_channels(token):
body = {
"filter": [{"type": "equals", "field": "active", "value": True}],
"associations": {"language": {}},
"total-count-mode": 1,
}
rows = api_search("sales-channel", token, body)
return [{"id": r["id"], "languageId": r["languageId"]} for r in rows]
def fetch_products(token, limit=PAGE_LIMIT):
page = 1
out = []
while True:
body = {
"page": page,
"limit": limit,
"associations": {"visibilities": {}},
"total-count-mode": 1,
}
rows = api_search("product", token, body)
for r in rows:
visibilities = r.get("visibilities") or []
channel_ids = [v["salesChannelId"] for v in visibilities]
out.append({"id": r["id"], "productNumber": r.get("productNumber"), "assignedChannelIds": channel_ids})
if len(rows) < limit:
return out
page += 1
def fetch_seo_url_rows(token, product_id):
body = {
"filter": [
{"type": "equals", "field": "foreignKey", "value": product_id},
{"type": "equals", "field": "routeName", "value": ROUTE_NAME},
{"type": "equals", "field": "isDeleted", "value": False},
],
"total-count-mode": 1,
}
rows = api_search("seo-url", token, body)
return [{"salesChannelId": r["salesChannelId"], "foreignKey": r["foreignKey"], "isDeleted": r["isDeleted"]} for r in rows]
def resave_product_name(token, product_id, current_name):
r = requests.patch(
f"{SHOPWARE_URL}/api/product/{product_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"name": current_name},
timeout=30,
)
r.raise_for_status()
def run():
token = get_token()
sales_channels = fetch_active_sales_channels(token)
products = fetch_products(token)
flagged = []
for product in products:
if not product["assignedChannelIds"]:
continue
rows = fetch_seo_url_rows(token, product["id"])
flagged.extend(decide_missing_seo_urls(product, sales_channels, rows))
if not flagged:
log.info("Done. 0 product(s) missing an seo_url row across %d product(s) checked.", len(products))
return
by_id = {p["id"]: p for p in products}
for item in flagged:
product = by_id[item["productId"]]
log.warning(
"Product %s (%s): missing seo_url on channel(s) %s",
item["productId"], product.get("productNumber"), ", ".join(item["missingChannelIds"]),
)
if DRY_RUN:
log.info("Done. %d product(s) flagged and reported. DRY_RUN=true, nothing was written.", len(flagged))
return
log.warning("DRY_RUN=false: attempting one guarded re-save per flagged product. "
"Only proceed if this store is confirmed patched (PRs #11673, #12098).")
fixed = 0
for item in flagged:
product = by_id[item["productId"]]
try:
details = requests.get(
f"{SHOPWARE_URL}/api/product/{product['id']}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=30,
)
details.raise_for_status()
current_name = details.json()["data"]["name"]
resave_product_name(token, product["id"], current_name)
log.info("Product %s: re-saved name to re-trigger SEO generation.", product["id"])
fixed += 1
except requests.HTTPError as exc:
log.error(
"Stopping batch. Product %s failed the guarded re-save (%s). "
"This is proof the instance is still vulnerable, review manually or upgrade.",
product["id"], exc,
)
break
log.info("Done. %d of %d flagged product(s) re-saved before stopping.", fixed, len(flagged))
if __name__ == "__main__":
run()
/**
* Detect Shopware 6 products missing an SEO URL on a sales channel that
* shares a language with another channel that already has one, without ever
* writing to seo_url directly.
*
* Shopware's seo_url table enforces a unique index across
* (language_id, sales_channel_id, route_name, foreign_key) through
* SeoUrlPersister::updateSeoUrls. A regression introduced in commit
* 73447962f84, confirmed from Shopware 6.6.10.6 onward in
* shopware/shopware#12143, causes the persister to treat a product's
* existing SEO URL row for one sales channel as already covering the
* language, and it silently skips inserting a row for a second sales channel
* that shares that language, or throws a UniqueConstraintViolationException
* (uniq.seo_url.foreign_key) that aborts the save. Either way the second
* channel ends up with no seo_url row for that product, so its storefront
* links fall back to /detail/{id}.
*
* This only ever reports affected products by default. The fix upstream is
* in PRs #11673 and #12098. Only when DRY_RUN is explicitly set to false
* does the script attempt a single guarded re-save of the product's own
* name, meant to be used only after confirming the store runs a patched
* Shopware version. It stops the whole batch on the first
* UniqueConstraintViolationException, because that is proof the instance is
* still vulnerable. Run on demand. Safe to run again and again in the
* default report-only mode.
*
* Guide: https://www.allanninal.dev/shopware/seo-url-missing-shared-language-channel/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAGE_LIMIT = 200;
const ROUTE_NAME = "frontend.detail.page";
export function decideMissingSeoUrls(product, salesChannels, existingSeoUrlRows) {
const knownChannelIds = new Set(salesChannels.map((sc) => sc.id));
const hasRow = new Set(
existingSeoUrlRows
.filter((row) => row.foreignKey === product.id && !row.isDeleted)
.map((row) => row.salesChannelId)
);
const missingChannelIds = product.assignedChannelIds.filter(
(chId) => knownChannelIds.has(chId) && !hasRow.has(chId)
);
if (missingChannelIds.length === 0) return [];
return [{ productId: product.id, missingChannelIds }];
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function apiSearch(entity, token, body) {
const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${entity} search`);
const data = await res.json();
return data.data;
}
async function fetchActiveSalesChannels(token) {
const body = {
filter: [{ type: "equals", field: "active", value: true }],
associations: { language: {} },
"total-count-mode": 1,
};
const rows = await apiSearch("sales-channel", token, body);
return rows.map((r) => ({ id: r.id, languageId: r.languageId }));
}
async function fetchProducts(token, limit = PAGE_LIMIT) {
let page = 1;
const out = [];
while (true) {
const body = {
page,
limit,
associations: { visibilities: {} },
"total-count-mode": 1,
};
const rows = await apiSearch("product", token, body);
for (const r of rows) {
const visibilities = r.visibilities || [];
const channelIds = visibilities.map((v) => v.salesChannelId);
out.push({ id: r.id, productNumber: r.productNumber, assignedChannelIds: channelIds });
}
if (rows.length < limit) return out;
page++;
}
}
async function fetchSeoUrlRows(token, productId) {
const body = {
filter: [
{ type: "equals", field: "foreignKey", value: productId },
{ type: "equals", field: "routeName", value: ROUTE_NAME },
{ type: "equals", field: "isDeleted", value: false },
],
"total-count-mode": 1,
};
const rows = await apiSearch("seo-url", token, body);
return rows.map((r) => ({ salesChannelId: r.salesChannelId, foreignKey: r.foreignKey, isDeleted: r.isDeleted }));
}
async function getProduct(token, productId) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${productId}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
if (!res.ok) throw new Error(`Shopware ${res.status} on product ${productId}`);
const data = await res.json();
return data.data;
}
async function resaveProductName(token, productId, currentName) {
const res = await fetch(`${SHOPWARE_URL}/api/product/${productId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ name: currentName }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} re-saving product ${productId}`);
}
export async function run() {
const token = await getToken();
const salesChannels = await fetchActiveSalesChannels(token);
const products = await fetchProducts(token);
const flagged = [];
for (const product of products) {
if (!product.assignedChannelIds.length) continue;
const rows = await fetchSeoUrlRows(token, product.id);
flagged.push(...decideMissingSeoUrls(product, salesChannels, rows));
}
if (!flagged.length) {
console.log(`Done. 0 product(s) missing an seo_url row across ${products.length} product(s) checked.`);
return;
}
const byId = new Map(products.map((p) => [p.id, p]));
for (const item of flagged) {
const product = byId.get(item.productId);
console.warn(
`Product ${item.productId} (${product?.productNumber}): missing seo_url on channel(s) ${item.missingChannelIds.join(", ")}`
);
}
if (DRY_RUN) {
console.log(`Done. ${flagged.length} product(s) flagged and reported. DRY_RUN=true, nothing was written.`);
return;
}
console.warn("DRY_RUN=false: attempting one guarded re-save per flagged product. "
+ "Only proceed if this store is confirmed patched (PRs #11673, #12098).");
let fixed = 0;
for (const item of flagged) {
const product = byId.get(item.productId);
try {
const details = await getProduct(token, product.id);
await resaveProductName(token, product.id, details.name);
console.log(`Product ${product.id}: re-saved name to re-trigger SEO generation.`);
fixed++;
} catch (err) {
console.error(
`Stopping batch. Product ${product.id} failed the guarded re-save (${err.message}). `
+ "This is proof the instance is still vulnerable, review manually or upgrade."
);
break;
}
}
console.log(`Done. ${fixed} of ${flagged.length} flagged product(s) re-saved before stopping.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping and flagging logic is the part most worth testing, because it decides which products actually have a missing SEO URL versus which are healthy. Because decide_missing_seo_urls is pure, the test needs no network and no live Shopware store.
from find_missing_seo_urls import decide_missing_seo_urls
def channel(id, language_id):
return {"id": id, "languageId": language_id}
def row(sales_channel_id, foreign_key, is_deleted=False):
return {"salesChannelId": sales_channel_id, "foreignKey": foreign_key, "isDeleted": is_deleted}
def test_no_missing_when_every_channel_has_a_row():
product = {"id": "p1", "assignedChannelIds": ["scA", "scB"]}
channels = [channel("scA", "lang-de"), channel("scB", "lang-de")]
rows = [row("scA", "p1"), row("scB", "p1")]
assert decide_missing_seo_urls(product, channels, rows) == []
def test_flags_second_channel_sharing_a_language_with_no_row():
product = {"id": "p1", "assignedChannelIds": ["scA", "scB"]}
channels = [channel("scA", "lang-de"), channel("scB", "lang-de")]
rows = [row("scA", "p1")]
result = decide_missing_seo_urls(product, channels, rows)
assert len(result) == 1
assert result[0]["productId"] == "p1"
assert result[0]["missingChannelIds"] == ["scB"]
def test_deleted_row_does_not_count_as_present():
product = {"id": "p1", "assignedChannelIds": ["scA", "scB"]}
channels = [channel("scA", "lang-de"), channel("scB", "lang-de")]
rows = [row("scA", "p1"), row("scB", "p1", is_deleted=True)]
result = decide_missing_seo_urls(product, channels, rows)
assert result[0]["missingChannelIds"] == ["scB"]
def test_unknown_channel_is_never_flagged():
product = {"id": "p1", "assignedChannelIds": ["scA", "sc-removed"]}
channels = [channel("scA", "lang-de")]
rows = [row("scA", "p1")]
assert decide_missing_seo_urls(product, channels, rows) == []
def test_rows_for_a_different_product_are_ignored():
product = {"id": "p1", "assignedChannelIds": ["scA", "scB"]}
channels = [channel("scA", "lang-de"), channel("scB", "lang-de")]
rows = [row("scA", "p1"), row("scB", "other-product")]
result = decide_missing_seo_urls(product, channels, rows)
assert result[0]["missingChannelIds"] == ["scB"]
def test_no_assigned_channels_flags_nothing():
product = {"id": "p1", "assignedChannelIds": []}
channels = [channel("scA", "lang-de")]
rows = []
assert decide_missing_seo_urls(product, channels, rows) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideMissingSeoUrls } from "./find-missing-seo-urls.js";
const channel = (id, languageId) => ({ id, languageId });
const row = (salesChannelId, foreignKey, isDeleted = false) => ({ salesChannelId, foreignKey, isDeleted });
test("no missing when every channel has a row", () => {
const product = { id: "p1", assignedChannelIds: ["scA", "scB"] };
const channels = [channel("scA", "lang-de"), channel("scB", "lang-de")];
const rows = [row("scA", "p1"), row("scB", "p1")];
assert.deepEqual(decideMissingSeoUrls(product, channels, rows), []);
});
test("flags second channel sharing a language with no row", () => {
const product = { id: "p1", assignedChannelIds: ["scA", "scB"] };
const channels = [channel("scA", "lang-de"), channel("scB", "lang-de")];
const rows = [row("scA", "p1")];
const result = decideMissingSeoUrls(product, channels, rows);
assert.equal(result.length, 1);
assert.equal(result[0].productId, "p1");
assert.deepEqual(result[0].missingChannelIds, ["scB"]);
});
test("deleted row does not count as present", () => {
const product = { id: "p1", assignedChannelIds: ["scA", "scB"] };
const channels = [channel("scA", "lang-de"), channel("scB", "lang-de")];
const rows = [row("scA", "p1"), row("scB", "p1", true)];
const result = decideMissingSeoUrls(product, channels, rows);
assert.deepEqual(result[0].missingChannelIds, ["scB"]);
});
test("unknown channel is never flagged", () => {
const product = { id: "p1", assignedChannelIds: ["scA", "sc-removed"] };
const channels = [channel("scA", "lang-de")];
const rows = [row("scA", "p1")];
assert.deepEqual(decideMissingSeoUrls(product, channels, rows), []);
});
test("rows for a different product are ignored", () => {
const product = { id: "p1", assignedChannelIds: ["scA", "scB"] };
const channels = [channel("scA", "lang-de"), channel("scB", "lang-de")];
const rows = [row("scA", "p1"), row("scB", "other-product")];
const result = decideMissingSeoUrls(product, channels, rows);
assert.deepEqual(result[0].missingChannelIds, ["scB"]);
});
test("no assigned channels flags nothing", () => {
const product = { id: "p1", assignedChannelIds: [] };
const channels = [channel("scA", "lang-de")];
const rows = [];
assert.deepEqual(decideMissingSeoUrls(product, channels, rows), []);
});
Case studies
The wholesale storefront that never showed up in search
A homeware brand ran a single German storefront for two years, every product ranking cleanly. They launched a second, B2B-only sales channel in the same language for trade buyers and reassigned their full catalog to it. Weeks later they noticed the wholesale channel's product pages were all raw /detail/{id} links, no keywords, no clean paths, and organic traffic on that channel stayed flat.
Running the detection script found every one of the reassigned products flagged, each missing an seo_url row on the new B2B channel while the original storefront's row was untouched and fine. The team reported it to their agency, confirmed their Shopware version predated the fix, and scheduled the upgrade before letting the guarded re-save run.
The headless frontend that silently fell back
A store added a headless sales channel next to its existing storefront to power a new app, both configured for English. The headless frontend consumed the Store API and expected a canonical SEO path for every product, but a chunk of products returned only the numeric detail route, breaking the app's own URL structure in ways nobody had budgeted time to debug.
The script's report matched the exact set of broken products to the exact set assigned to both channels, immediately pointing at the shared-language regression instead of a bug in the app's own code. Once the platform was confirmed patched, the guarded re-save fixed the batch in one run.
After running this, every product missing an SEO URL on one of its assigned sales channels shows up in one report, with the exact channel ids it is missing on and a sample product number for quick lookup. Nothing is written to seo_url directly, and the one guarded re-save only ever runs against a confirmed patched Shopware version, stopping immediately if it sees the unique constraint violation again. The storefront stops silently falling back to raw detail links on channels nobody was watching.
FAQ
Why does a product have no SEO URL on a second sales channel?
This happens when the product already has a canonical SEO URL for one sales channel and is then assigned to a second sales channel that shares the same language. A regression in SeoUrlPersister, confirmed as shopware/shopware#12143, treats the existing row as already covering that language and silently skips inserting a row for the second channel, or throws a unique constraint violation that aborts the save. Either way the second channel ends up with no seo_url row for that product.
How do I find products missing an SEO URL on one of their sales channels?
List active sales channels with their languageId, then for each product read its visibilities to get the assigned sales channel ids. For each product and channel pair, search seo-url filtered by foreignKey, salesChannelId, routeName frontend.detail.page, and isDeleted false. If a channel has zero rows while a sibling channel sharing the same languageId already has a row for that product, the product is flagged as affected.
Can I just re-save the product to fix the missing SEO URL automatically?
Only if the store is confirmed to be on a Shopware version at or after the fix in PRs #11673 and #12098. On an unpatched instance, re-triggering generation can reproduce the same unique constraint violation, so the safe default is to report the affected products for a human to review, and only attempt the guarded re-save, one product at a time with the first failure stopping the batch, once the version is confirmed patched.
Related field notes
Citations
On the problem:
- SEO URL is not generated when a product is assigned to multiple sales channels with the same language (shopware/shopware #12143). github.com/shopware/shopware/issues/12143
- Sales channel URLs are required to be unique, however with the store-api and in other scenarios they do not need to be (shopware/shopware #3246). github.com/shopware/shopware/issues/3246
- Deep dive into Shopware SEO URLs, how they work, how to configure them, and how to avoid growth issues, BrocksiNet. brocksi.net/blog/seo-urls-deep-dive-shopware-6
On the solution:
- Add Custom SEO URLs, SeoUrlUpdater and SeoUrlPersister, Shopware Developer Docs. developer.shopware.com add-custom-seo-url
- Admin API Entity Reference. shopware.stoplight.io entity-reference
- Shopware 6 Settings, SEO settings. docs.shopware.com/en/shopware-6-en/settings/seo
Stuck on a tricky one?
If you have a problem in Shopware orders, payments, catalog, or SEO that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this explain a missing product URL?
If this saved you a confusing SEO gap, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi