Diagnostic URL Rewrites
URL rewrites not generated on product edit or duplicate
You save a product through the REST API, or you duplicate one in the admin grid, and everything looks fine. No error, a clean 200 OK. Then someone clicks the product link and gets a 404. Nothing wrote a url_rewrite row for it. This is not a misconfiguration. It is a long-standing core bug in how Magento resolves the store scope for a saved product, and it fails silently every time. Here is why it happens and a small script that finds the affected SKUs before your customers do.
Magento regenerates a product's url_rewrite rows on catalog_product_save_after through Magento\CatalogUrlRewrite\Observer\ProductProcessUrlRewriteSavingObserver, which asks Product::getStoreIds() which stores to write for. In single-store mode, and reliably when the save comes from PUT /V1/products/{sku} instead of the admin form, getStoreIds() runs a spurious array_keys() over an already-keyed website_ids array and resolves the wrong or an empty store scope, so the generator writes nothing. No exception, still a 200 OK. A duplicated product follows the exact same save path, so it inherits no rewrite at all. Run a small Python or Node.js script that reads a product's url_key and website scope over REST, computes the request_path it should have per store, and checks the live storefront URL for a 404. There is no public API to insert the missing row safely, so the script reports affected SKUs and the documented workaround rather than writing blind. Full code, tests, and sources are below.
The problem in plain words
Every time you save a product, Magento is supposed to make sure its friendly URL still works. It listens for catalog_product_save_after, and a dedicated observer regenerates whatever url_rewrite rows that product needs for every store it is visible in. That part works exactly as expected almost all of the time.
The part that breaks is figuring out which stores to generate for. Magento asks the product itself, through getStoreIds(), and that method has a bug in how it reads website_ids. In a normal admin save this usually resolves fine. But in single-store mode, and consistently when the product was saved through PUT /V1/products/{sku} rather than clicked and saved in the admin form, the method calls array_keys() on an array that is already keyed by id, which returns the wrong values, sometimes an empty set. The rewrite generator is handed that broken scope, decides there is nothing to do, and quietly returns. The save itself still succeeds. Nothing in the response tells you the rewrite step failed.
Why it happens
This is a confirmed core bug, not a merchant misconfiguration, and it has been reported across several Magento 2.x versions. A few shapes it takes:
- A single-store mode installation saves a product and the rewrite is not generated at all, since
getStoreIds()takes a shortcut through the website id array that assumes a shape it does not have, a pattern reported inmagento/magento2issue #25190. - A product updated through
PUT /V1/products/{sku}with a new or unchangedurl_keydoes not get a new rewrite row, even though the same edit made through the admin form works correctly, reported inmagento/magento2issue #30316. - Editing or duplicating a product in the admin grid can also hit this path and silently skip the rewrite, tracked in
magento/magento2issue #33884. - Because
ProductRepository::save()for a duplicated product runs through the identicalcatalog_product_save_afterandgetStoreIds()resolution, a duplicate can be created with zero rewrite rows from the moment it exists.
The reason this is so easy to miss is that nothing fails loudly. The REST call returns 200. The product record itself has a perfectly valid url_key. It is only the join between the product and the url_rewrite table that never happened, and that only shows up when a real visitor or a crawler hits the URL and gets a 404. See the citations at the end for the exact threads.
A product's url_key being correct tells you nothing about whether its url_rewrite row exists. Those are two different systems, and the bug lives entirely in the second one, in how Magento decides which stores to write rows for. So detection cannot stop at reading the product's custom attributes. It has to compute the request_path Magento should be serving and actually check the storefront for it, store by store, because that is the only place this failure becomes visible.
The fix, as a flow
There is no public API to write a url_rewrite row directly, and no safe way to auto-insert one without risking a collision with a row that already claims that request_path. So the script's job is to find the gap precisely and hand you the documented workaround, not to silently patch the table itself. It reads the product's scope and url_key over REST, works out the expected request_path per store, and checks the live storefront URL. Anything that 404s where it should not gets flagged with the exact remediation, gated behind a dry run.
Build it step by step
Get an admin bearer token
Call POST /rest/V1/integration/admin/token with your admin username and password, or use a preconfigured integration token. Either way you end up with a bearer token you send as Authorization: Bearer <token> on every call. Keep the token and the store URL in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true" # start safe, change to false to confirm a re-save workaround
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true" // start safe, change to false to confirm a re-save workaround
Talk to the Magento REST API
Every call goes to {MAGENTO_URL}/rest/V1 with your token in the Authorization header. A small helper sends the request and raises on a non success status, and we reuse it for reading the product, reading store views, and reading store config.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Read the product, the store views, and the URL suffix
GET /rest/V1/products/{sku} gives you the product's url_key custom attribute and extension_attributes.website_ids. GET /rest/V1/store/storeViews resolves the store codes and ids the product should be visible in. GET /rest/V1/store/storeConfigs?storeCodes[]=... reads the catalog/seo/product_url_suffix path per store, defaulting to .html when the store does not set one.
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_product(sku):
item = api_get(f"/products/{sku}")
return {
"sku": item["sku"],
"url_key": custom_attr(item.get("custom_attributes"), "url_key"),
"website_ids": (item.get("extension_attributes") or {}).get("website_ids", []),
}
def fetch_store_views():
return api_get("/store/storeViews")
def fetch_url_suffix(store_code):
configs = api_get("/store/storeConfigs", {"storeCodes[]": store_code})
if not configs:
return ".html"
return configs[0].get("product_url_suffix") or ".html"
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function fetchProduct(sku) {
const item = await apiGet(`/products/${sku}`);
return {
sku: item.sku,
url_key: customAttr(item.custom_attributes, "url_key"),
website_ids: item.extension_attributes?.website_ids || [],
};
}
async function fetchStoreViews() {
return apiGet("/store/storeViews");
}
async function fetchUrlSuffix(storeCode) {
const configs = await apiGet("/store/storeConfigs", { "storeCodes[]": storeCode });
if (!configs.length) return ".html";
return configs[0].product_url_suffix || ".html";
}
Decide, with one pure function
Keep the decision in its own function that takes the product, the URL suffix, and a pre-fetched map of store id to the request_paths that already exist for that store, and returns the pairs that are missing a rewrite. It never touches the network itself, it only computes and compares, which makes it trivial to test with fixture data instead of a live store.
def is_url_rewrite_missing(product, expected_suffix, existing_rewrite_paths):
missing = []
for store_id in product["storeIds"]:
expected_path = f"{product['urlKey']}{expected_suffix}"
known_paths = existing_rewrite_paths.get(store_id, set())
if expected_path not in known_paths:
missing.append({
"sku": product["sku"],
"storeId": store_id,
"expectedPath": expected_path,
})
return missing
export function isUrlRewriteMissing(product, expectedSuffix, existingRewritePaths) {
const missing = [];
for (const storeId of product.storeIds) {
const expectedPath = `${product.urlKey}${expectedSuffix}`;
const knownPaths = existingRewritePaths.get(storeId) || new Set();
if (!knownPaths.has(expectedPath)) {
missing.push({ sku: product.sku, storeId, expectedPath });
}
}
return missing;
}
Check the storefront, since there is no rewrite search endpoint
Core Magento has no public /V1/url-rewrites search resource, so the only outside confirmation that a row exists is the storefront itself. For each store's base URL, request {store_base_url}/{expected_path} and treat a 404 as proof the rewrite is missing, while a 301 or 200 means it resolved. This is what actually feeds the existingRewritePaths map the pure function reads.
def path_resolves(store_base_url, expected_path):
r = requests.head(f"{store_base_url.rstrip('/')}/{expected_path}", timeout=15, allow_redirects=False)
if r.status_code == 405:
r = requests.get(f"{store_base_url.rstrip('/')}/{expected_path}", timeout=15, allow_redirects=False)
return r.status_code in (200, 301, 302)
async function pathResolves(storeBaseUrl, expectedPath) {
const url = `${storeBaseUrl.replace(/\/+$/, "")}/${expectedPath}`;
let res = await fetch(url, { method: "HEAD", redirect: "manual" });
if (res.status === 405) res = await fetch(url, { method: "GET", redirect: "manual" });
return [200, 301, 302].includes(res.status);
}
Report, guarded by a dry run, never write blind
There is no public API to insert a url_rewrite row, and a blind write risks a URL_REWRITE_REQUEST_PATH_STORE_ID collision with a row that already claims that path. So the script always reports affected SKUs with the store id and expected path. With DRY_RUN=false it also issues the documented workaround, a PUT /V1/products/{sku} with extension_attributes.website_ids intentionally duplicated, for example [1, 1], which forces getStoreIds() down a code path that resolves correctly. The alternative, resaving through the Admin UI or running bin/magento indexer:reindex catalog_url_rewrite, is CLI or admin only and is reported as an option, not executed.
Always start with DRY_RUN=true and read the report before writing anything. Never insert or edit url_rewrite rows directly, and treat the duplicated website_ids workaround as a deliberate, documented core workaround, not a guess.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever repairs a SKU by resaving it through the normal REST API with the documented workaround, one at a time.
"""Detect Magento products whose url_rewrite row was not generated on
save or duplicate, and report the safe repair.
ProductProcessUrlRewriteSavingObserver regenerates url_rewrite rows on
catalog_product_save_after using Product::getStoreIds() to resolve which
stores to write for. In single-store mode, and reliably when a product is
saved through PUT /V1/products/{sku} instead of the admin form,
getStoreIds() mishandles website_ids and resolves the wrong or an empty
scope, so no rewrite row is written. No exception is thrown and the save
still returns 200 OK. There is no public API to insert a url_rewrite row
directly, and a blind write risks a URL_REWRITE_REQUEST_PATH_STORE_ID
collision, so this script only reports affected SKUs and, when DRY_RUN is
explicitly disabled, applies the documented re-save workaround. Report
only by default.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("url_rewrite_missing")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SKUS = [s.strip() for s in os.environ.get("CHECK_SKUS", "").split(",") if s.strip()]
STORE_BASE_URLS = os.environ.get("STORE_BASE_URLS", "") # "1:https://store1.example.com,2:https://store2.example.com"
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, payload):
r = requests.put(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, json=payload, timeout=30)
r.raise_for_status()
return r.json()
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_product(sku):
item = api_get(f"/products/{sku}")
return {
"sku": item["sku"],
"urlKey": custom_attr(item.get("custom_attributes"), "url_key"),
"storeIds": (item.get("extension_attributes") or {}).get("website_ids", []),
}
def fetch_url_suffix(store_code):
configs = api_get("/store/storeConfigs", {"storeCodes[]": store_code})
if not configs:
return ".html"
return configs[0].get("product_url_suffix") or ".html"
def parse_store_base_urls(raw):
mapping = {}
for pair in raw.split(","):
pair = pair.strip()
if not pair or ":" not in pair:
continue
store_id, url = pair.split(":", 1)
mapping[int(store_id)] = url
return mapping
def is_url_rewrite_missing(product, expected_suffix, existing_rewrite_paths):
missing = []
for store_id in product["storeIds"]:
expected_path = f"{product['urlKey']}{expected_suffix}"
known_paths = existing_rewrite_paths.get(store_id, set())
if expected_path not in known_paths:
missing.append({
"sku": product["sku"],
"storeId": store_id,
"expectedPath": expected_path,
})
return missing
def path_resolves(store_base_url, expected_path):
r = requests.head(f"{store_base_url.rstrip('/')}/{expected_path}", timeout=15, allow_redirects=False)
if r.status_code == 405:
r = requests.get(f"{store_base_url.rstrip('/')}/{expected_path}", timeout=15, allow_redirects=False)
return r.status_code in (200, 301, 302)
def repair_with_duplicated_website_ids(sku, website_ids):
doubled = list(website_ids) + list(website_ids)
payload = {"product": {"sku": sku, "extension_attributes": {"website_ids": doubled}}}
return api_put(f"/products/{sku}", payload)
def run():
store_base_urls = parse_store_base_urls(STORE_BASE_URLS)
flagged = 0
for sku in SKUS:
product = fetch_product(sku)
if not product["urlKey"]:
log.warning("SKU %s has no url_key, skipping", sku)
continue
existing_rewrite_paths = {}
for store_id in product["storeIds"]:
base_url = store_base_urls.get(store_id)
if not base_url:
log.warning("No STORE_BASE_URLS entry for store_id=%s, skipping check", store_id)
continue
suffix = fetch_url_suffix(str(store_id))
expected_path = f"{product['urlKey']}{suffix}"
existing_rewrite_paths[store_id] = (
{expected_path} if path_resolves(base_url, expected_path) else set()
)
default_suffix = ".html"
missing = is_url_rewrite_missing(product, default_suffix, existing_rewrite_paths)
for gap in missing:
log.warning(
"Missing url_rewrite: sku=%s store_id=%s expected_path=%s",
gap["sku"], gap["storeId"], gap["expectedPath"],
)
flagged += 1
if missing:
log.info(
"%s sku=%s website_ids=%s (duplicated workaround)",
"Would PUT" if DRY_RUN else "PUTting",
sku, product["storeIds"],
)
if not DRY_RUN:
repair_with_duplicated_website_ids(sku, product["storeIds"])
log.info("Done. %d missing rewrite(s) found.", flagged)
if __name__ == "__main__":
run()
/**
* Detect Magento products whose url_rewrite row was not generated on
* save or duplicate, and report the safe repair.
*
* ProductProcessUrlRewriteSavingObserver regenerates url_rewrite rows on
* catalog_product_save_after using Product::getStoreIds() to resolve which
* stores to write for. In single-store mode, and reliably when a product is
* saved through PUT /V1/products/{sku} instead of the admin form,
* getStoreIds() mishandles website_ids and resolves the wrong or an empty
* scope, so no rewrite row is written, with no exception and a 200 OK. There
* is no public API to insert a url_rewrite row directly, so this script only
* reports affected SKUs and, when DRY_RUN is explicitly disabled, applies the
* documented re-save workaround.
*
* Guide: https://www.allanninal.dev/magento/url-rewrite-not-generated-on-edit/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "dummy-token";
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SKUS = (process.env.CHECK_SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
const STORE_BASE_URLS = process.env.STORE_BASE_URLS || "";
export function isUrlRewriteMissing(product, expectedSuffix, existingRewritePaths) {
const missing = [];
for (const storeId of product.storeIds) {
const expectedPath = `${product.urlKey}${expectedSuffix}`;
const knownPaths = existingRewritePaths.get(storeId) || new Set();
if (!knownPaths.has(expectedPath)) {
missing.push({ sku: product.sku, storeId, expectedPath });
}
}
return missing;
}
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function apiPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function fetchProduct(sku) {
const item = await apiGet(`/products/${sku}`);
return {
sku: item.sku,
urlKey: customAttr(item.custom_attributes, "url_key"),
storeIds: item.extension_attributes?.website_ids || [],
};
}
async function fetchUrlSuffix(storeCode) {
const configs = await apiGet("/store/storeConfigs", { "storeCodes[]": storeCode });
if (!configs.length) return ".html";
return configs[0].product_url_suffix || ".html";
}
function parseStoreBaseUrls(raw) {
const mapping = new Map();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed || !trimmed.includes(":")) continue;
const idx = trimmed.indexOf(":");
const storeId = Number(trimmed.slice(0, idx));
const url = trimmed.slice(idx + 1);
mapping.set(storeId, url);
}
return mapping;
}
async function pathResolves(storeBaseUrl, expectedPath) {
const url = `${storeBaseUrl.replace(/\/+$/, "")}/${expectedPath}`;
let res = await fetch(url, { method: "HEAD", redirect: "manual" });
if (res.status === 405) res = await fetch(url, { method: "GET", redirect: "manual" });
return [200, 301, 302].includes(res.status);
}
async function repairWithDuplicatedWebsiteIds(sku, websiteIds) {
const doubled = [...websiteIds, ...websiteIds];
const payload = { product: { sku, extension_attributes: { website_ids: doubled } } };
return apiPut(`/products/${sku}`, payload);
}
export async function run() {
const storeBaseUrls = parseStoreBaseUrls(STORE_BASE_URLS);
let flagged = 0;
for (const sku of SKUS) {
const product = await fetchProduct(sku);
if (!product.urlKey) {
console.warn(`SKU ${sku} has no url_key, skipping`);
continue;
}
const existingRewritePaths = new Map();
for (const storeId of product.storeIds) {
const baseUrl = storeBaseUrls.get(storeId);
if (!baseUrl) {
console.warn(`No STORE_BASE_URLS entry for store_id=${storeId}, skipping check`);
continue;
}
const suffix = await fetchUrlSuffix(String(storeId));
const expectedPath = `${product.urlKey}${suffix}`;
const resolved = await pathResolves(baseUrl, expectedPath);
existingRewritePaths.set(storeId, resolved ? new Set([expectedPath]) : new Set());
}
const defaultSuffix = ".html";
const missing = isUrlRewriteMissing(product, defaultSuffix, existingRewritePaths);
for (const gap of missing) {
console.warn(`Missing url_rewrite: sku=${gap.sku} store_id=${gap.storeId} expected_path=${gap.expectedPath}`);
flagged++;
}
if (missing.length) {
console.log(`${DRY_RUN ? "Would PUT" : "PUTting"} sku=${sku} website_ids=${JSON.stringify(product.storeIds)} (duplicated workaround)`);
if (!DRY_RUN) await repairWithDuplicatedWebsiteIds(sku, product.storeIds);
}
}
console.log(`Done. ${flagged} missing rewrite(s) found.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which SKU and store pairs get reported as missing a rewrite in front of a real storefront. Because is_url_rewrite_missing is pure, the test needs no network and no Magento store. It just feeds in plain objects and checks the answer.
from url_rewrite_missing import is_url_rewrite_missing
def product(**over):
base = {"sku": "GREEN-SHIRT", "urlKey": "green-shirt", "storeIds": [1]}
base.update(over)
return base
def test_no_gap_when_expected_path_already_known():
existing = {1: {"green-shirt.html"}}
result = is_url_rewrite_missing(product(), ".html", existing)
assert result == []
def test_flags_missing_when_store_has_no_matching_path():
existing = {1: set()}
result = is_url_rewrite_missing(product(), ".html", existing)
assert result == [{"sku": "GREEN-SHIRT", "storeId": 1, "expectedPath": "green-shirt.html"}]
def test_flags_missing_when_store_id_absent_from_map():
existing = {}
result = is_url_rewrite_missing(product(), ".html", existing)
assert result == [{"sku": "GREEN-SHIRT", "storeId": 1, "expectedPath": "green-shirt.html"}]
def test_checks_every_store_the_product_belongs_to():
p = product(storeIds=[1, 2])
existing = {1: {"green-shirt.html"}, 2: set()}
result = is_url_rewrite_missing(p, ".html", existing)
assert result == [{"sku": "GREEN-SHIRT", "storeId": 2, "expectedPath": "green-shirt.html"}]
def test_no_stores_means_no_gaps():
p = product(storeIds=[])
result = is_url_rewrite_missing(p, ".html", {})
assert result == []
def test_respects_a_custom_suffix():
existing = {1: {"green-shirt.htm"}}
result = is_url_rewrite_missing(product(), ".htm", existing)
assert result == []
def test_wrong_suffix_in_existing_paths_is_still_a_gap():
existing = {1: {"green-shirt.htm"}}
result = is_url_rewrite_missing(product(), ".html", existing)
assert result == [{"sku": "GREEN-SHIRT", "storeId": 1, "expectedPath": "green-shirt.html"}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { isUrlRewriteMissing } from "./url-rewrite-missing.js";
const product = (over = {}) => ({ sku: "GREEN-SHIRT", urlKey: "green-shirt", storeIds: [1], ...over });
test("no gap when expected path already known", () => {
const existing = new Map([[1, new Set(["green-shirt.html"])]]);
assert.deepEqual(isUrlRewriteMissing(product(), ".html", existing), []);
});
test("flags missing when store has no matching path", () => {
const existing = new Map([[1, new Set()]]);
assert.deepEqual(isUrlRewriteMissing(product(), ".html", existing), [
{ sku: "GREEN-SHIRT", storeId: 1, expectedPath: "green-shirt.html" },
]);
});
test("flags missing when store id absent from map", () => {
const existing = new Map();
assert.deepEqual(isUrlRewriteMissing(product(), ".html", existing), [
{ sku: "GREEN-SHIRT", storeId: 1, expectedPath: "green-shirt.html" },
]);
});
test("checks every store the product belongs to", () => {
const p = product({ storeIds: [1, 2] });
const existing = new Map([[1, new Set(["green-shirt.html"])], [2, new Set()]]);
assert.deepEqual(isUrlRewriteMissing(p, ".html", existing), [
{ sku: "GREEN-SHIRT", storeId: 2, expectedPath: "green-shirt.html" },
]);
});
test("no stores means no gaps", () => {
const p = product({ storeIds: [] });
assert.deepEqual(isUrlRewriteMissing(p, ".html", new Map()), []);
});
test("respects a custom suffix", () => {
const existing = new Map([[1, new Set(["green-shirt.htm"])]]);
assert.deepEqual(isUrlRewriteMissing(product(), ".htm", existing), []);
});
test("wrong suffix in existing paths is still a gap", () => {
const existing = new Map([[1, new Set(["green-shirt.htm"])]]);
assert.deepEqual(isUrlRewriteMissing(product(), ".html", existing), [
{ sku: "GREEN-SHIRT", storeId: 1, expectedPath: "green-shirt.html" },
]);
});
Case studies
A repriced product quietly lost its page
A single-store shop updated a batch of products through a nightly script that called PUT /V1/products/{sku} for pricing changes only, never touching url_key. A few weeks later, support tickets started coming in about specific product pages 404ing, the exact shape reported in magento/magento2 issue #25190. Nothing in the update script's logs showed an error, because there was not one to show.
Running the detection script against the affected SKUs confirmed the expected request_path 404d on the storefront while the product itself was enabled and in stock. The re-save workaround with duplicated website_ids regenerated the missing rows, and the team added the check to their post-deploy script going forward.
A duplicated seasonal variant never got a URL
A merchandiser duplicated a bestselling product in the admin grid to create a seasonal variant, changed the SKU and a few attributes, and published it. Marketing linked to it in an email campaign the same day, matching the pattern in magento/magento2 issue #33884, and the link 404d for every recipient.
Because the duplicate's save ran through the same broken store scope resolution as any other save, it had zero url_rewrite rows from creation. The team resaved the product through the Admin UI, which uses a different code path unaffected by the bug, and the URL resolved immediately. The detection script now runs against newly duplicated SKUs before they are linked anywhere.
After this runs against your recently edited or duplicated SKUs, a missing url_rewrite row shows up as a clear report with the SKU, the store id, and the exact path that 404s, instead of a support ticket days later. Nothing is written to the table directly, so there is never a collision risk, and the documented workaround gives whoever runs the fix a precise, safe action instead of a guess.
FAQ
Why does editing or duplicating a product not create a url_rewrite row?
Magento regenerates url_rewrite rows for a saved product through ProductProcessUrlRewriteSavingObserver, which relies on Product::getStoreIds() to know which stores to write rows for. In single-store mode, and especially when the save comes from PUT /V1/products/{sku} rather than the admin form, getStoreIds() mishandles website_ids and resolves the wrong or an empty store scope, so the generator is called with nothing to write. No exception is thrown and the API still returns 200 OK, so the missing rewrite goes unnoticed until the product URL 404s.
Can I just call an API to regenerate the missing url_rewrite row?
There is no public REST endpoint to insert or regenerate a url_rewrite row directly, and writing to the table by hand risks a URL_REWRITE_REQUEST_PATH_STORE_ID collision with an existing row. The documented workaround is to resave the product over REST with extension_attributes.website_ids intentionally duplicated, for example [1, 1], which forces getStoreIds() down a code path that resolves correctly, or to resave the product through the Admin UI, which uses a different, unaffected code path.
Does duplicating a product in Magento copy its URL rewrite?
No. A duplicated product is a new entity with its own entity_id, and ProductRepository::save() for that duplicate runs through the same ProductProcessUrlRewriteSavingObserver and the same getStoreIds() resolution as any other save. When that resolution is wrong, the duplicate ends up with no url_rewrite row at all, so its product page 404s until it is resaved correctly.
Related field notes
Citations
On the problem:
- magento/magento2: URL rewrites are not generated when editing or duplicating a product. github.com/magento/magento2/issues/33884
- magento/magento2: REST API Product update url_key does not re-generate url_rewrites. github.com/magento/magento2/issues/30316
- magento/magento2: URL rewrites are not being generated when Single-Store Mode is enabled. github.com/magento/magento2/issues/25190
On the solution:
- Adobe Commerce: Product URL Rewrites documentation. experienceleague.adobe.com url-rewrite-product
- Adobe Commerce/Magento REST API: Products endpoint reference. developer.adobe.com orders-search-with-searchcriteria
- Adobe Commerce Web API: store views and store config quick reference. developer.adobe.com quick-reference
Stuck on a tricky one?
If you have a problem in Magento catalog data, URL rewrites, cron, or MSI stock 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 clear your 404s?
If this saved you a broken product link or a confusing afternoon staring at a save that returned 200 OK for nothing, 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