Reconciler
Duplicate friendly URL slug collides across different products or categories
A product page 404s, or worse, opens the wrong product, and nobody touched anything in the back office. Somewhere a bulk import, a Duplicate click, or a webservice call saved a link_rewrite that another product or category already owns. PrestaShop's own admin form would have stopped it. Whatever wrote this one did not go through that form. Here is why that gap exists and a script that finds every collision and safely renames the one losing the URL.
PrestaShop enforces link_rewrite uniqueness only inside the admin form's own validation path, the Product and Category controllers calling ObjectModel validation, which checks uniqueness per id_lang and per shop context. That check never compares a category slug against product slugs, and never checks across shops in a multistore setup. Any path that skips that controller, bulk CSV or XML import, the Duplicate product action, direct SQL edits, or webservice PUT or POST calls, can insert or update a slug that collides with an existing product or category. Run a Python or Node.js script that pulls every product and category link_rewrite per language and shop, groups them by (id_shop, id_lang, link_rewrite), and flags any group with more than one member. Full code, tests, and a dry run guarded repair are below.
The problem in plain words
A friendly URL slug is supposed to point at exactly one thing. Type /red-sneakers into the address bar and you expect one product, every time. PrestaShop stores that slug in the link_rewrite field, one value per language, and for multistore setups one per shop, on both products and categories.
The back office form checks that the slug you type is unique before it lets you save. But that check lives inside the admin controller, not inside the database, and it only compares within the same resource type and the same shop. It never asks "does any category already use this," and it never asks "does another shop already use this." So anything that writes link_rewrite without going through that specific form, a CSV import, the Duplicate button, a direct SQL update, or a webservice call, can save a slug that already belongs to something else. Nothing stops it at write time, and nothing announces the mistake until a shopper hits a 404 or lands on the wrong page.
Why it happens
The root cause is that uniqueness for link_rewrite is enforced in one narrow place, not at the data layer. Documented ways it shows up:
- Uniqueness is checked by the Product and Category admin controllers calling
ObjectModelvalidation when you save through the back office form, and only there. - That check is scoped per
id_lang, and for multistore-aware entities per shop, becauselink_rewriteis stored in per-language and per-shop tables. It is never compared across resource types, so a category slug is never checked against product slugs, and vice versa. - Bulk CSV or XML import, the "Duplicate" product action, direct SQL edits, and webservice
PUTorPOSTcalls all writelink_rewritewithout replicating that controller's validation, so any of them can insert or update a slug that already exists elsewhere. - In a multistore or multi-domain setup, the same slug can be reused safely across two different shops, but if those shops share a URL scheme or domain, the same collision problem appears between shops too.
- At resolve time, the friendly-URL dispatcher does a lookup that returns the first matching row. Only one of the colliding entities is ever reachable through its pretty URL, the other silently 404s or resolves to the wrong page.
This is not something you will see flagged anywhere in the back office. Both records look completely normal in their own edit screens, and the only visible symptom is a storefront URL that goes to the wrong place, or nowhere at all. See the citations at the end for the exact threads and issues.
A collision is not something a script should just resolve by guessing which one matters more. PrestaShop's own dispatcher already tells you which one wins: whichever row the lookup returns first, in practice the older, lower-id record. So the safe pattern mirrors that behavior. Keep the lowest id as the canonical keeper, exactly like the dispatcher's own first-match rule, and only rename every other member of the collision group. And because a slug is a public URL, treat the rename itself as something to log and confirm before writing, not something to fire blind.
The fix, as a flow
We do not touch the live storefront. We add a job that pulls every product and category link_rewrite through the webservice, groups them by shop, language, and slug, and reports any group with more than one member. For each collision, the lowest id keeps its slug, and every other member gets a deterministic new slug that a guarded write can apply.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with read access to products and categories, and write access if you plan to let the repair actually rename anything. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, change to false to write
Pull every product and category link_rewrite
Call GET /api/products?display=[id,active,link_rewrite]&filter[active]=1&limit=0&output_format=JSON and the same shape against /api/categories. link_rewrite comes back as an array of {id, language[@id], value} entries in JSON, one per language, so unwrap that per language id before grouping. If the shop is multistore, repeat with filter[id_shop]=X per shop, since link_rewrite is stored in a per-shop table and only enforced unique inside a single shop.
import os, requests
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def resource_records(resource, id_shop=None):
params = {"display": "[id,active,link_rewrite]", "filter[active]": "1", "limit": "0"}
if id_shop is not None:
params["filter[id_shop]"] = str(id_shop)
data = api_get(resource, params=params)
return data.get(resource) or []
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function resourceRecords(resource, idShop) {
const params = { display: "[id,active,link_rewrite]", "filter[active]": "1", limit: "0" };
if (idShop !== undefined) params["filter[id_shop]"] = String(idShop);
const data = await apiGet(resource, params);
return data[resource] || [];
}
Flatten per-language slugs into flat records
Each product or category comes back with a link_rewrite array keyed by language. Unwrap that into one flat record per (type, id, id_lang, id_shop, link_rewrite) combination, which is exactly the shape the pure collision detector expects. This is the only place API shape leaks into the script, everything after this point works on plain dicts.
def flatten_records(resource_type, raw_items, id_shop):
records = []
for item in raw_items:
entries = item.get("link_rewrite") or []
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
lang = entry.get("language") or {}
id_lang = int(lang.get("@id", lang.get("id", 1)))
records.append({
"type": resource_type,
"id": int(item["id"]),
"id_lang": id_lang,
"id_shop": id_shop,
"link_rewrite": entry.get("value", entry.get("#text", "")),
})
return records
function flattenRecords(resourceType, rawItems, idShop) {
const records = [];
for (const item of rawItems) {
let entries = item.link_rewrite || [];
if (!Array.isArray(entries)) entries = [entries];
for (const entry of entries) {
const lang = entry.language || {};
const idLang = Number(lang["@id"] ?? lang.id ?? 1);
records.push({
type: resourceType,
id: Number(item.id),
id_lang: idLang,
id_shop: idShop,
link_rewrite: entry.value ?? entry["#text"] ?? "",
});
}
}
return records;
}
Decide, with one pure function
Keep the grouping in its own function that takes the flattened records and returns every collision it finds. It groups by the composite key (id_shop, id_lang, link_rewrite), so a product and a category sharing a slug in the same shop and language is a collision, but the same slug reused safely across two different shops is not. Within each collision, the lowest id becomes the keeper, matching PrestaShop's own first-match dispatcher behavior, and every other member gets a deterministic new slug built from its own type and id, which is collision-free by construction.
def find_slug_collisions(records):
groups = {}
for rec in records:
key = (rec["id_shop"], rec["id_lang"], rec["link_rewrite"])
groups.setdefault(key, []).append(rec)
collisions = []
for (id_shop, id_lang, link_rewrite), members in groups.items():
if len(members) < 2:
continue
ordered = sorted(members, key=lambda r: r["id"])
keeper = ordered[0]
renames = [{
"type": m["type"],
"id": m["id"],
"old_slug": m["link_rewrite"],
"new_slug": f"{m['link_rewrite']}-{m['type']}-{m['id']}",
} for m in ordered[1:]]
collisions.append({
"id_shop": id_shop,
"id_lang": id_lang,
"link_rewrite": link_rewrite,
"keeper": {"type": keeper["type"], "id": keeper["id"]},
"renames": renames,
})
return collisions
export function findSlugCollisions(records) {
const groups = new Map();
for (const rec of records) {
const key = JSON.stringify([rec.id_shop, rec.id_lang, rec.link_rewrite]);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(rec);
}
const collisions = [];
for (const [key, members] of groups) {
if (members.length < 2) continue;
const [idShop, idLang, linkRewrite] = JSON.parse(key);
const ordered = [...members].sort((a, b) => a.id - b.id);
const keeper = ordered[0];
const renames = ordered.slice(1).map((m) => ({
type: m.type,
id: m.id,
old_slug: m.link_rewrite,
new_slug: `${m.link_rewrite}-${m.type}-${m.id}`,
}));
collisions.push({
id_shop: idShop,
id_lang: idLang,
link_rewrite: linkRewrite,
keeper: { type: keeper.type, id: keeper.id },
renames,
});
}
return collisions;
}
Apply the rename with a full-resource PUT
The webservice requires a full resource body on PUT, not a partial patch. Fetch GET /api/{resource}/{id}?output_format=JSON first, mutate only the link_rewrite entry for the affected id_lang, then send the whole object back with PUT /api/{resource}/{id}. Print a recommended redirect line for every rename, since the old URL is now dead and a 301 rule is the safe follow-up, kept as a manual step and out of scope for the script itself.
RESOURCE_PATH = {"product": "products", "category": "categories"}
def apply_rename(rename, id_lang):
resource = RESOURCE_PATH[rename["type"]]
full = api_get(f"{resource}/{rename['id']}")
node = full[resource[:-1]]
entries = node["link_rewrite"]
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
lang = entry.get("language") or {}
if int(lang.get("@id", lang.get("id", 1))) == id_lang:
entry["value"] = rename["new_slug"]
node["link_rewrite"] = entries
r = requests.put(
f"{PRESTASHOP_URL}/api/{resource}/{rename['id']}",
params={"output_format": "JSON"},
json=full,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
const RESOURCE_PATH = { product: "products", category: "categories" };
async function applyRename(rename, idLang) {
const resource = RESOURCE_PATH[rename.type];
const full = await apiGet(`${resource}/${rename.id}`);
const singular = resource.slice(0, -1);
const node = full[singular];
let entries = node.link_rewrite;
if (!Array.isArray(entries)) entries = [entries];
for (const entry of entries) {
const lang = entry.language || {};
if (Number(lang["@id"] ?? lang.id ?? 1) === idLang) entry.value = rename.new_slug;
}
node.link_rewrite = entries;
const url = new URL(`${PRESTASHOP_URL}/api/${resource}/${rename.id}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(full),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${resource}/${rename.id}`);
}
Wire it together with a dry run guard
The loop ties every piece together: pull products and categories per shop, flatten them, detect collisions with find_slug_collisions, and for every rename either log the plan or apply the PUT, depending on DRY_RUN. Leave DRY_RUN on for the first runs, read the planned renames, agree with them, then switch it off. Run it on a schedule that matches how often you import catalog data, for example right after every bulk import job.
Always start with DRY_RUN=true. A rename changes a public URL, so pair every applied rename with a Dispatcher or web server 301 redirect from the old slug to the new one, otherwise existing inbound links, bookmarks, and search engine index entries break. The script prints the recommended redirect line for you, but adding the rule itself stays a manual step.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pulls products and categories per shop, flattens and groups their link_rewrite values, and renames every non-keeper member of a collision behind the dry run guard, printing a recommended redirect for each one.
"""Find and safely rename PrestaShop link_rewrite slugs that collide across
products or categories.
PrestaShop enforces link_rewrite uniqueness only inside the admin form's own
validation path, the Product and Category controllers calling ObjectModel
validation, which checks uniqueness per id_lang and per shop context. It never
checks across resource types, a category slug is never compared against
product slugs, and never checks across shops in a multistore or multi-domain
setup. Bulk CSV or XML import, the Duplicate product action, direct SQL edits,
or webservice PUT or POST calls that do not replicate that controller check
can all insert or update a slug that collides with an existing product or
category. At resolve time the friendly-URL dispatcher looks up the first
matching row, so only one of the colliding entities is ever reachable through
its pretty URL, the other silently 404s or resolves to the wrong page.
This script detects every collision, keeps the lowest id in each group as the
canonical keeper (matching the dispatcher's own first-match behavior), and
renames every other member to a deterministic, collision-free slug. Renaming
is guarded by DRY_RUN, which defaults to true, since a rename changes a
public URL. When DRY_RUN is false, every rename also prints a recommended
Dispatcher or web server 301 redirect rule as a manual follow-up step, since
adding that rule is out of scope for this script.
Run on a schedule, ideally right after any bulk import job. Safe to run again
and again: an already-renamed slug will not collide a second time.
Guide: https://www.allanninal.dev/prestashop/duplicate-friendly-url-slug/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_duplicate_friendly_url_slug")
PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SHOP_IDS = [int(s) for s in os.environ.get("PRESTASHOP_SHOP_IDS", "").split(",") if s.strip()]
AUTH = (PRESTASHOP_WS_KEY, "")
RESOURCE_PATH = {"product": "products", "category": "categories"}
def find_slug_collisions(records):
"""Pure decision function, no I/O.
records is a list of {"type": "product"|"category", "id": int, "id_lang": int,
"id_shop": int, "link_rewrite": str}. Groups records by the composite key
(id_shop, id_lang, link_rewrite); any group with more than one member is a
collision. The lowest id in each group is kept as the canonical keeper, matching
PrestaShop's own first-match dispatcher behavior, and every other member is marked
needs_rename with a deterministic new slug of f"{link_rewrite}-{type}-{id}", which
is collision-free by construction since id is unique per type.
"""
groups = {}
for rec in records:
key = (rec["id_shop"], rec["id_lang"], rec["link_rewrite"])
groups.setdefault(key, []).append(rec)
collisions = []
for (id_shop, id_lang, link_rewrite), members in groups.items():
if len(members) < 2:
continue
ordered = sorted(members, key=lambda r: r["id"])
keeper = ordered[0]
renames = [{
"type": m["type"],
"id": m["id"],
"old_slug": m["link_rewrite"],
"new_slug": f"{m['link_rewrite']}-{m['type']}-{m['id']}",
} for m in ordered[1:]]
collisions.append({
"id_shop": id_shop,
"id_lang": id_lang,
"link_rewrite": link_rewrite,
"keeper": {"type": keeper["type"], "id": keeper["id"]},
"renames": renames,
})
return collisions
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def resource_records(resource, id_shop=None):
params = {"display": "[id,active,link_rewrite]", "filter[active]": "1", "limit": "0"}
if id_shop is not None:
params["filter[id_shop]"] = str(id_shop)
data = api_get(resource, params=params)
return data.get(resource) or []
def flatten_records(resource_type, raw_items, id_shop):
records = []
for item in raw_items:
entries = item.get("link_rewrite") or []
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
lang = entry.get("language") or {}
id_lang = int(lang.get("@id", lang.get("id", 1)))
records.append({
"type": resource_type,
"id": int(item["id"]),
"id_lang": id_lang,
"id_shop": id_shop if id_shop is not None else 1,
"link_rewrite": entry.get("value", entry.get("#text", "")),
})
return records
def collect_records():
shops = SHOP_IDS or [None]
all_records = []
for id_shop in shops:
products = resource_records("products", id_shop)
categories = resource_records("categories", id_shop)
all_records.extend(flatten_records("product", products, id_shop))
all_records.extend(flatten_records("category", categories, id_shop))
return all_records
def apply_rename(rename, id_lang):
resource = RESOURCE_PATH[rename["type"]]
full = api_get(f"{resource}/{rename['id']}")
node = full[resource[:-1]]
entries = node["link_rewrite"]
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
lang = entry.get("language") or {}
if int(lang.get("@id", lang.get("id", 1))) == id_lang:
entry["value"] = rename["new_slug"]
node["link_rewrite"] = entries
r = requests.put(
f"{PRESTASHOP_URL}/api/{resource}/{rename['id']}",
params={"output_format": "JSON"},
json=full,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
def run():
records = collect_records()
collisions = find_slug_collisions(records)
renamed = 0
for collision in collisions:
id_lang = collision["id_lang"]
for rename in collision["renames"]:
log.warning(
"Slug collision. id_shop=%s id_lang=%s type=%s id=%s old_slug=%s %s new_slug=%s",
collision["id_shop"], id_lang, rename["type"], rename["id"],
rename["old_slug"], "would rename to" if DRY_RUN else "renaming to",
rename["new_slug"],
)
if not DRY_RUN:
apply_rename(rename, id_lang)
log.info(
"Recommended redirect: 301 /%s -> /%s (id_shop=%s)",
rename["old_slug"], rename["new_slug"], collision["id_shop"],
)
renamed += 1
log.info(
"Done. %d slug(s) %s across %d collision group(s). DRY_RUN=%s.",
renamed, "to rename" if DRY_RUN else "renamed", len(collisions), DRY_RUN,
)
if __name__ == "__main__":
run()
/**
* Find and safely rename PrestaShop link_rewrite slugs that collide across
* products or categories.
*
* PrestaShop enforces link_rewrite uniqueness only inside the admin form's own
* validation path, the Product and Category controllers calling ObjectModel
* validation, which checks uniqueness per id_lang and per shop context. It
* never checks across resource types, a category slug is never compared
* against product slugs, and never checks across shops in a multistore or
* multi-domain setup. Bulk CSV or XML import, the Duplicate product action,
* direct SQL edits, or webservice PUT or POST calls that do not replicate
* that controller check can all insert or update a slug that collides with
* an existing product or category. At resolve time the friendly-URL
* dispatcher looks up the first matching row, so only one of the colliding
* entities is ever reachable through its pretty URL, the other silently
* 404s or resolves to the wrong page.
*
* This script detects every collision, keeps the lowest id in each group as
* the canonical keeper (matching the dispatcher's own first-match behavior),
* and renames every other member to a deterministic, collision-free slug.
* Renaming is guarded by DRY_RUN, which defaults to true, since a rename
* changes a public URL. When DRY_RUN is false, every rename also prints a
* recommended Dispatcher or web server 301 redirect rule as a manual
* follow-up step, since adding that rule is out of scope for this script.
*
* Run on a schedule, ideally right after any bulk import job.
*
* Guide: https://www.allanninal.dev/prestashop/duplicate-friendly-url-slug/
*/
import { pathToFileURL } from "node:url";
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SHOP_IDS = (process.env.PRESTASHOP_SHOP_IDS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(Number);
const RESOURCE_PATH = { product: "products", category: "categories" };
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* records is an array of {type: "product"|"category", id, id_lang, id_shop,
* link_rewrite}. Groups records by the composite key (id_shop, id_lang,
* link_rewrite); any group with more than one member is a collision. The
* lowest id in each group is kept as the canonical keeper, matching
* PrestaShop's own first-match dispatcher behavior, and every other member
* is marked needs_rename with a deterministic new slug of
* `${link_rewrite}-${type}-${id}`, which is collision-free by construction
* since id is unique per type.
*/
export function findSlugCollisions(records) {
const groups = new Map();
for (const rec of records) {
const key = JSON.stringify([rec.id_shop, rec.id_lang, rec.link_rewrite]);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(rec);
}
const collisions = [];
for (const [key, members] of groups) {
if (members.length < 2) continue;
const [idShop, idLang, linkRewrite] = JSON.parse(key);
const ordered = [...members].sort((a, b) => a.id - b.id);
const keeper = ordered[0];
const renames = ordered.slice(1).map((m) => ({
type: m.type,
id: m.id,
old_slug: m.link_rewrite,
new_slug: `${m.link_rewrite}-${m.type}-${m.id}`,
}));
collisions.push({
id_shop: idShop,
id_lang: idLang,
link_rewrite: linkRewrite,
keeper: { type: keeper.type, id: keeper.id },
renames,
});
}
return collisions;
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function resourceRecords(resource, idShop) {
const params = { display: "[id,active,link_rewrite]", "filter[active]": "1", limit: "0" };
if (idShop !== undefined) params["filter[id_shop]"] = String(idShop);
const data = await apiGet(resource, params);
return data[resource] || [];
}
function flattenRecords(resourceType, rawItems, idShop) {
const records = [];
for (const item of rawItems) {
let entries = item.link_rewrite || [];
if (!Array.isArray(entries)) entries = [entries];
for (const entry of entries) {
const lang = entry.language || {};
const idLang = Number(lang["@id"] ?? lang.id ?? 1);
records.push({
type: resourceType,
id: Number(item.id),
id_lang: idLang,
id_shop: idShop !== undefined ? idShop : 1,
link_rewrite: entry.value ?? entry["#text"] ?? "",
});
}
}
return records;
}
async function collectRecords() {
const shops = SHOP_IDS.length ? SHOP_IDS : [undefined];
const all = [];
for (const idShop of shops) {
const products = await resourceRecords("products", idShop);
const categories = await resourceRecords("categories", idShop);
all.push(...flattenRecords("product", products, idShop));
all.push(...flattenRecords("category", categories, idShop));
}
return all;
}
async function applyRename(rename, idLang) {
const resource = RESOURCE_PATH[rename.type];
const full = await apiGet(`${resource}/${rename.id}`);
const singular = resource.slice(0, -1);
const node = full[singular];
let entries = node.link_rewrite;
if (!Array.isArray(entries)) entries = [entries];
for (const entry of entries) {
const lang = entry.language || {};
if (Number(lang["@id"] ?? lang.id ?? 1) === idLang) entry.value = rename.new_slug;
}
node.link_rewrite = entries;
const url = new URL(`${PRESTASHOP_URL}/api/${resource}/${rename.id}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(full),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${resource}/${rename.id}`);
}
export async function run() {
const records = await collectRecords();
const collisions = findSlugCollisions(records);
let renamed = 0;
for (const collision of collisions) {
const idLang = collision.id_lang;
for (const rename of collision.renames) {
console.warn(
`Slug collision. id_shop=${collision.id_shop} id_lang=${idLang} type=${rename.type} ` +
`id=${rename.id} old_slug=${rename.old_slug} ${DRY_RUN ? "would rename to" : "renaming to"} ` +
`new_slug=${rename.new_slug}`
);
if (!DRY_RUN) {
await applyRename(rename, idLang);
console.log(
`Recommended redirect: 301 /${rename.old_slug} -> /${rename.new_slug} (id_shop=${collision.id_shop})`
);
}
renamed++;
}
}
console.log(
`Done. ${renamed} slug(s) ${DRY_RUN ? "to rename" : "renamed"} across ${collisions.length} collision group(s). DRY_RUN=${DRY_RUN}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping and rename-planning rule is the part most worth testing, because it decides which slug wins and what every renamed slug becomes. Because we kept find_slug_collisions pure, the test needs no network and no PrestaShop store. It just feeds in plain records and checks the answer.
from fix_duplicate_friendly_url_slug import find_slug_collisions
def record(**over):
base = {"type": "product", "id": 1, "id_lang": 1, "id_shop": 1, "link_rewrite": "red-sneakers"}
base.update(over)
return base
def test_no_collisions_when_all_slugs_unique():
rows = [
record(id=1, link_rewrite="red-sneakers"),
record(id=2, link_rewrite="blue-sneakers"),
]
assert find_slug_collisions(rows) == []
def test_two_products_share_a_slug_in_one_language_only():
rows = [
record(id=1, id_lang=1, link_rewrite="red-sneakers"),
record(id=2, id_lang=1, link_rewrite="red-sneakers"),
record(id=1, id_lang=2, link_rewrite="baskets-rouges"),
record(id=2, id_lang=2, link_rewrite="baskets-bleues"),
]
collisions = find_slug_collisions(rows)
assert len(collisions) == 1
assert collisions[0]["id_lang"] == 1
assert collisions[0]["keeper"] == {"type": "product", "id": 1}
assert collisions[0]["renames"] == [{
"type": "product", "id": 2, "old_slug": "red-sneakers", "new_slug": "red-sneakers-product-2",
}]
def test_product_and_category_collide_with_each_other():
rows = [
record(type="product", id=5, link_rewrite="red-sneakers"),
record(type="category", id=2, link_rewrite="red-sneakers"),
]
collisions = find_slug_collisions(rows)
assert len(collisions) == 1
assert collisions[0]["keeper"] == {"type": "category", "id": 2}
assert collisions[0]["renames"] == [{
"type": "product", "id": 5, "old_slug": "red-sneakers", "new_slug": "red-sneakers-product-5",
}]
def test_same_slug_reused_safely_across_two_shops_is_not_a_collision():
rows = [
record(id_shop=1, link_rewrite="red-sneakers"),
record(id_shop=2, link_rewrite="red-sneakers"),
]
assert find_slug_collisions(rows) == []
def test_three_way_collision_keeps_lowest_id_and_renames_the_rest():
rows = [
record(type="product", id=9, link_rewrite="red-sneakers"),
record(type="product", id=3, link_rewrite="red-sneakers"),
record(type="category", id=7, link_rewrite="red-sneakers"),
]
collisions = find_slug_collisions(rows)
assert len(collisions) == 1
assert collisions[0]["keeper"] == {"type": "product", "id": 3}
renamed_ids = {(r["type"], r["id"]) for r in collisions[0]["renames"]}
assert renamed_ids == {("category", 7), ("product", 9)}
def test_no_records_no_collisions():
assert find_slug_collisions([]) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findSlugCollisions } from "./fix-duplicate-friendly-url-slug.js";
const record = (over = {}) => ({
type: "product",
id: 1,
id_lang: 1,
id_shop: 1,
link_rewrite: "red-sneakers",
...over,
});
test("no collisions when all slugs unique", () => {
const rows = [
record({ id: 1, link_rewrite: "red-sneakers" }),
record({ id: 2, link_rewrite: "blue-sneakers" }),
];
assert.deepEqual(findSlugCollisions(rows), []);
});
test("two products share a slug in one language only", () => {
const rows = [
record({ id: 1, id_lang: 1, link_rewrite: "red-sneakers" }),
record({ id: 2, id_lang: 1, link_rewrite: "red-sneakers" }),
record({ id: 1, id_lang: 2, link_rewrite: "baskets-rouges" }),
record({ id: 2, id_lang: 2, link_rewrite: "baskets-bleues" }),
];
const collisions = findSlugCollisions(rows);
assert.equal(collisions.length, 1);
assert.equal(collisions[0].id_lang, 1);
assert.deepEqual(collisions[0].keeper, { type: "product", id: 1 });
assert.deepEqual(collisions[0].renames, [
{ type: "product", id: 2, old_slug: "red-sneakers", new_slug: "red-sneakers-product-2" },
]);
});
test("product and category collide with each other", () => {
const rows = [
record({ type: "product", id: 5, link_rewrite: "red-sneakers" }),
record({ type: "category", id: 2, link_rewrite: "red-sneakers" }),
];
const collisions = findSlugCollisions(rows);
assert.equal(collisions.length, 1);
assert.deepEqual(collisions[0].keeper, { type: "category", id: 2 });
assert.deepEqual(collisions[0].renames, [
{ type: "product", id: 5, old_slug: "red-sneakers", new_slug: "red-sneakers-product-5" },
]);
});
test("same slug reused safely across two shops is not a collision", () => {
const rows = [
record({ id_shop: 1, link_rewrite: "red-sneakers" }),
record({ id_shop: 2, link_rewrite: "red-sneakers" }),
];
assert.deepEqual(findSlugCollisions(rows), []);
});
test("three way collision keeps lowest id and renames the rest", () => {
const rows = [
record({ type: "product", id: 9, link_rewrite: "red-sneakers" }),
record({ type: "product", id: 3, link_rewrite: "red-sneakers" }),
record({ type: "category", id: 7, link_rewrite: "red-sneakers" }),
];
const collisions = findSlugCollisions(rows);
assert.equal(collisions.length, 1);
assert.deepEqual(collisions[0].keeper, { type: "product", id: 3 });
const renamedIds = new Set(collisions[0].renames.map((r) => `${r.type}:${r.id}`));
assert.deepEqual(renamedIds, new Set(["category:7", "product:9"]));
});
test("no records no collisions", () => {
assert.deepEqual(findSlugCollisions([]), []);
});
Case studies
The migration that quietly ate a category page
A furniture store migrated its catalog from a spreadsheet using a CSV import that set link_rewrite directly from a "URL slug" column, one column that mixed both product and category rows. One product row and one category row happened to share the same cleaned-up name, and the import went through without a single warning, since the importer never checked across resource types.
Weeks later, a customer complained that clicking through to the "Dining Tables" category from a marketing email landed on an unrelated single product instead. Running the detector against the whole catalog found the exact colliding pair immediately, the store renamed the newer product row behind a dry run first, added the recommended redirect, and the category page came back.
The two shops that shared a domain scheme
A multistore install ran two shops on subdomains of the same root domain, each shop free to reuse the same link_rewrite values since PrestaShop only enforces uniqueness within a single shop. That was fine until an SEO audit tool, crawling both subdomains through a shared CDN cache rule, started reporting mixed content between the two shops' otherwise separate catalogs.
Running the script with PRESTASHOP_SHOP_IDS set to both shop ids surfaced which slugs were genuinely safe reuse across shops, versus a handful that had been imported with a shared vendor feed and were actually meant to be shop specific. The team renamed only the accidental duplicates and left the intentional shared slugs alone.
After this runs on a schedule, a colliding slug turns into a clear, logged plan naming the keeper and every rename, instead of a silent 404 a customer finds before you do. Renames only ever happen behind DRY_RUN=false, and every rename comes with a printed redirect recommendation so the old URL is not just abandoned. The lowest id always keeps its slug, matching what the storefront was already showing anyway, so nothing that was working correctly gets disturbed.
FAQ
Why can two different products or categories end up with the same friendly URL slug?
PrestaShop only checks link_rewrite uniqueness inside the admin Product and Category controllers when you save through the back office form. That check never compares a category slug against product slugs, and never checks across shops in a multistore setup. Bulk CSV or XML import, the Duplicate product action, direct SQL edits, and webservice PUT or POST calls all bypass that specific controller path, so any of them can insert a link_rewrite that collides with an existing product or category.
What happens when a product and a category share the same slug?
The friendly URL dispatcher resolves both product and category slugs from the same URL namespace, so a product and a category can collide with each other, not only product against product. At resolve time the dispatcher looks up the first matching row, so only one of the two colliding entities is ever reachable through its pretty URL, while the other silently 404s or resolves to the wrong page.
Is it safe to auto-rename a colliding link_rewrite slug?
Renaming the losing record's slug is safe for the site's internal consistency, but it changes a public URL, which can break existing inbound links, bookmarks, and search engine index entries. That is why the fix keeps DRY_RUN true by default, only writes when you explicitly turn it off, and prints a recommended 301 redirect entry as a manual follow-up step for every rename it makes.
Related field notes
Citations
On the problem:
- PrestaShop Forums: Duplicated Friendly Url (Link_Rewrite) Issue. prestashop.com/forums/topic/225091-duplicated-friendly-url-link_rewrite-issue
- PrestaShop GitHub: Seo Url, incorrect category path in product url. Issue #31827. github.com/PrestaShop/PrestaShop/issues/31827
- PrestaShop Specs: SEO Rules & Behaviours. build.prestashop-project.org/prestashop-specs/1.7/broader-topics/seo-rules-and-behaviours.html
On the solution:
- PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products/
- PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories/
- PrestaShop Developer Documentation: The PrestaShop Webservice API, Getting Started. devdocs.prestashop-project.org/9/webservice/getting-started/
Stuck on a tricky one?
If you have a problem in PrestaShop catalog data, SEO, multistore setups, or the webservice API 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 untangle your friendly URLs?
If this saved you a broken category page or an SEO ranking scare, 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