Diagnostic
Duplicating a product keeps the original's friendly URL unchanged
You click Duplicate in the product list to save yourself some typing, and PrestaShop hands you a fresh product with a new id, set to disabled, ready to edit. Everything looks right until you check its SEO tab: the friendly URL is word for word the same slug as the product you copied it from. Nothing crashed. No warning showed up. Here is why the clone keeps the exact same slug and a script that finds every one of these collisions and fixes the ones it is safe to fix.
PrestaShop's Duplicate action, backed by ObjectModel::duplicateObject() and Product::duplicate(), copies the source product's rows with a raw INSERT ... SELECT, including every ps_product_lang row, then only rewrites id_product and a few flags like active. The friendly URL, stored in link_rewrite, is only ever regenerated from the product name inside AdminProductsController's form-save path, using Tools::link_rewrite() or str2url(), and that path never runs during duplication. So the clone keeps the identical link_rewrite as the original, in every language, and because PrestaShop's product URL uses id_product plus link_rewrite as the unique key rather than link_rewrite alone, both products stay reachable and nothing errors. Run a Python or Node.js script that pulls every product's link_rewrite per language, groups by (id_lang, link_rewrite), and flags any group with more than one member as a collision to fix or review. Full code, tests, and a dry run guarded repair are below.
The problem in plain words
Duplicate is meant to save you re-entering a product's price, description, images, and category tree when you are adding a close variant of something you already sell. PrestaShop does exactly that: it copies the whole product record, gives the copy a new id, and disables it so you can finish setting it up before it goes live.
But the copy is a literal row-for-row copy, including the link_rewrite field that holds the friendly URL slug. PrestaShop only builds a fresh slug from the product name when you actually type into the Name field and save the form. Duplication never touches the Name field and never runs that save path, so the slug that gets copied is the exact same text as the original's slug. Two different products, two different ids, one identical friendly URL.
Why it happens
The root cause is where PrestaShop puts its slug-generation logic: inside one specific controller action, not inside the model that duplication actually calls. Ways it shows up:
ObjectModel::duplicateObject()andProduct::duplicate()clone the source row set with a raw SQL copy, and only rewriteid_productand a small number of flags such asactive = 0.Tools::link_rewrite()andstr2url(), the functions that turn a product name into a URL slug, are only ever invoked from insideAdminProductsController's form-save path, triggered when the Name field is actually edited and saved.- The duplication flow never runs that form-save path, so
link_rewriteis copied byte for byte from the original into the newps_product_langrows, for every language the shop supports. - PrestaShop's product URL is unique on
id_productpluslink_rewritetogether, not onlink_rewritealone, so no SQL unique-index error is ever raised. Both products remain individually reachable through their own id. - The collision is silent until two different product ids resolve to visually identical URLs, which shows up as canonical confusion, or as duplicate-content signals reported by search engines.
Nothing in the back office calls this out. The duplicate looks completely normal in its own edit screen, disabled and waiting to be finished, and the only visible symptom is that its SEO tab shows the same friendly URL text as the product it came from. See the citations at the end for the exact threads and issues.
A collision from duplication is not the same problem as two unrelated products that happen to want the same slug. Here we already know which one is the original and which one is the copy, since PrestaShop assigns ids in creation order. The safe move is not to guess a brand-new slug from scratch, but to keep the original's slug on the original and append the duplicate's own id to make the copy's slug unique and traceable back to its source. And because a name that has drifted far from the original usually means someone already started differentiating that product, that case is worth a human's judgment, not an automatic guess.
The fix, as a flow
We do not touch the live storefront. We add a job that pulls every product's link_rewrite and name per language through the webservice, groups them by (id_lang, link_rewrite), and for each group with more than one member keeps the earliest product and proposes a new slug for every later one, unless the name has diverged too far to guess safely.
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 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's slug, name, and date
Call GET /api/products?display=[id,id_default_image,link_rewrite,name,date_add]&output_format=JSON&limit=0. Both link_rewrite and name come back as multilingual nodes, an array of {language: [{"@attributes":{"id":"1"},"#text":"desktop-computer"}, ...]}, so unwrap the #text per language id before grouping.
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 all_products():
data = api_get("products", params={
"display": "[id,id_default_image,link_rewrite,name,date_add]",
"limit": "0",
})
return data.get("products") 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 allProducts() {
const data = await apiGet("products", {
display: "[id,id_default_image,link_rewrite,name,date_add]",
limit: "0",
});
return data.products || [];
}
Flatten per-language nodes into flat records
Each product comes back with link_rewrite and name arrays keyed by language. Unwrap both into one flat record per (id, id_lang) combination, which is exactly the shape the pure decision function expects. This is the only place the API's multilingual shape leaks into the script.
def _entries(value):
if isinstance(value, dict):
return [value]
return value or []
def _lang_id(entry):
lang = entry.get("language") or {}
attrs = lang.get("@attributes") or lang
return int(attrs.get("id", 1))
def flatten_by_lang(raw_products):
"""Returns {id_lang: [{"id": int, "link_rewrite": str, "name": str, "date_add": str}, ...]}"""
by_lang = {}
for item in raw_products:
slug_entries = {_lang_id(e): e.get("#text", "") for e in _entries(item.get("link_rewrite"))}
name_entries = {_lang_id(e): e.get("#text", "") for e in _entries(item.get("name"))}
for id_lang, slug in slug_entries.items():
by_lang.setdefault(id_lang, []).append({
"id": int(item["id"]),
"link_rewrite": slug,
"name": name_entries.get(id_lang, ""),
"date_add": item.get("date_add", ""),
})
return by_lang
function entries(value) {
if (value == null) return [];
return Array.isArray(value) ? value : [value];
}
function langId(entry) {
const lang = entry.language || {};
const attrs = lang["@attributes"] || lang;
return Number(attrs.id ?? 1);
}
function flattenByLang(rawProducts) {
// Returns { [id_lang]: [{ id, link_rewrite, name, date_add }, ...] }
const byLang = {};
for (const item of rawProducts) {
const slugEntries = new Map(entries(item.link_rewrite).map((e) => [langId(e), e["#text"] || ""]));
const nameEntries = new Map(entries(item.name).map((e) => [langId(e), e["#text"] || ""]));
for (const [idLang, slug] of slugEntries) {
if (!byLang[idLang]) byLang[idLang] = [];
byLang[idLang].push({
id: Number(item.id),
link_rewrite: slug,
name: nameEntries.get(idLang) || "",
date_add: item.date_add || "",
});
}
}
return byLang;
}
Decide, with one pure function
Keep the decision in its own function that takes the flattened records for a single language and returns only the entries that need to change. It groups by link_rewrite, keeps the earliest by date_add in each group untouched, and gives every other member a new slug built from its own id, checked against the whole product set so it never collides with anything else, including another group's own repair.
def suffix_duplicate_slugs(products, id_lang):
groups = {}
for p in products:
groups.setdefault(p["link_rewrite"], []).append(p)
all_slugs = {p["link_rewrite"] for p in products}
changes = []
for slug, members in groups.items():
if len(members) < 2:
continue
ordered = sorted(members, key=lambda p: (p["date_add"] or "", p["id"]))
for p in ordered[1:]:
candidate = f"{slug}-{p['id']}"
while candidate in all_slugs:
candidate = f"{candidate}-dup"
all_slugs.add(candidate)
changes.append({"id": p["id"], "old_slug": slug, "new_slug": candidate})
return changes
export function suffixDuplicateSlugs(products, idLang) {
const groups = new Map();
for (const p of products) {
if (!groups.has(p.link_rewrite)) groups.set(p.link_rewrite, []);
groups.get(p.link_rewrite).push(p);
}
const allSlugs = new Set(products.map((p) => p.link_rewrite));
const changes = [];
for (const [slug, members] of groups) {
if (members.length < 2) continue;
const ordered = [...members].sort((a, b) => {
const da = a.date_add || "";
const db = b.date_add || "";
if (da !== db) return da < db ? -1 : 1;
return a.id - b.id;
});
for (const p of ordered.slice(1)) {
let candidate = `${slug}-${p.id}`;
while (allSlugs.has(candidate)) candidate = `${candidate}-dup`;
allSlugs.add(candidate);
changes.push({ id: p.id, old_slug: slug, new_slug: candidate });
}
}
return changes;
}
Skip diverged names, then apply with a full-resource PUT
Before writing, compare each duplicate's name against its group's earliest member. When the name has drifted far, treat it as an already-differentiated product whose old slug was just never touched, and only report it. Otherwise fetch GET /api/products/{id}?output_format=JSON, mutate only the link_rewrite entry for the affected id_lang, then send the whole product back with PUT, since the webservice expects the complete resource, not a partial patch.
def names_diverged(original_name, duplicate_name):
a = (original_name or "").strip().lower()
b = (duplicate_name or "").strip().lower()
if not a or not b:
return True
return a not in b and b not in a
def apply_rename(product_id, new_slug, id_lang):
full = api_get(f"products/{product_id}")
node = full["product"]
entries = node["link_rewrite"]
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
lang = entry.get("language") or {}
attrs = lang.get("@attributes") or lang
if int(attrs.get("id", 1)) == id_lang:
entry["#text"] = new_slug
node["link_rewrite"] = entries
r = requests.put(
f"{PRESTASHOP_URL}/api/products/{product_id}",
params={"output_format": "JSON"},
json=full,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
function namesDiverged(originalName, duplicateName) {
const a = (originalName || "").trim().toLowerCase();
const b = (duplicateName || "").trim().toLowerCase();
if (!a || !b) return true;
return !a.includes(b) && !b.includes(a);
}
async function applyRename(productId, newSlug, idLang) {
const full = await apiGet(`products/${productId}`);
const node = full.product;
let entries = node.link_rewrite;
if (!Array.isArray(entries)) entries = [entries];
for (const entry of entries) {
const lang = entry.language || {};
const attrs = lang["@attributes"] || lang;
if (Number(attrs.id ?? 1) === idLang) entry["#text"] = newSlug;
}
node.link_rewrite = entries;
const url = new URL(`${PRESTASHOP_URL}/api/products/${productId}`);
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 products/${productId}`);
}
Wire it together with a dry run guard
The loop ties every piece together: pull all products, flatten per language, run suffix_duplicate_slugs per id_lang, then for every proposed change either apply the PUT or only log it, depending on DRY_RUN and whether the names still match closely enough to trust an automatic fix. Leave DRY_RUN on for the first runs, read the planned renames, agree with them, then switch it off. Run it after any bulk duplication session or on a nightly schedule.
Always start with DRY_RUN=true. Never let the auto-fix touch a duplicate whose name has diverged significantly from the original, since that usually means a human already turned the copy into its own product and simply never got to the SEO tab. Flag those for a human instead of guessing a slug from an unrelated name.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pulls every product's slug and name per language, groups and repairs collisions with suffix_duplicate_slugs, skips diverged names, and writes only behind the dry run guard.
"""Find and safely fix PrestaShop products whose friendly URL was copied
verbatim from the product they were duplicated from.
PrestaShop's Duplicate action, backed by ObjectModel::duplicateObject() and
Product::duplicate(), clones the source product's rows with a raw
INSERT ... SELECT copy, including every ps_product_lang row, then only
rewrites id_product and a few flags such as active. The friendly URL slug in
link_rewrite is only ever regenerated from the product name inside
AdminProductsController's form-save path, using Tools::link_rewrite() or
str2url(), triggered when the Name field is actually edited and saved. The
duplication flow never runs that path, so the clone keeps the identical
link_rewrite as the original in every language. Because the product URL is
unique on id_product plus link_rewrite together, not on link_rewrite alone,
no SQL error is raised: both products stay reachable, and the collision only
shows up as visually identical URLs, canonical confusion, and duplicate
content signals to search engines.
This script detects every collision, keeps the earliest product in each
group (by date_add) as the canonical original, and proposes a deterministic
new slug for every later duplicate. It skips any duplicate whose name has
diverged significantly from the original, since that usually means a human
already turned the copy into its own product and just never touched its
slug, a case that should be flagged for a human to rename from the SEO tab
instead of guessed automatically. Writing is guarded by DRY_RUN, which
defaults to true, since a rename changes a public URL.
Run after any bulk duplication session, or on a nightly schedule. Safe to
run again and again: an already-renamed slug will not collide a second time.
Guide: https://www.allanninal.dev/prestashop/duplicated-product-keeps-original-slug/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_duplicated_product_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"
AUTH = (PRESTASHOP_WS_KEY, "")
def suffix_duplicate_slugs(products, id_lang):
"""Pure decision function, no I/O.
products is a list of {"id": int, "link_rewrite": str, "name": str,
"date_add": str} already resolved to a single language (id_lang is kept
only for readability/logging at the call site). Groups records by
link_rewrite; any group with more than one member is a collision. The
earliest member by date_add (falling back to id) is kept unchanged, and
every other member gets new_slug = f"{old_slug}-{id}", extended with a
"-dup" suffix if that candidate still collides with any other slug
already present in the full product set, including another group's own
repair. Returns only the changed entries.
"""
groups = {}
for p in products:
groups.setdefault(p["link_rewrite"], []).append(p)
all_slugs = {p["link_rewrite"] for p in products}
changes = []
for slug, members in groups.items():
if len(members) < 2:
continue
ordered = sorted(members, key=lambda p: (p["date_add"] or "", p["id"]))
for p in ordered[1:]:
candidate = f"{slug}-{p['id']}"
while candidate in all_slugs:
candidate = f"{candidate}-dup"
all_slugs.add(candidate)
changes.append({"id": p["id"], "old_slug": slug, "new_slug": candidate})
return changes
def names_diverged(original_name, duplicate_name):
"""True when the duplicate's name no longer resembles the original's,
meaning the slug should only be flagged for a human, never auto-fixed."""
a = (original_name or "").strip().lower()
b = (duplicate_name or "").strip().lower()
if not a or not b:
return True
return a not in b and b not in a
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 _entries(value):
if isinstance(value, dict):
return [value]
return value or []
def _lang_id(entry):
lang = entry.get("language") or {}
attrs = lang.get("@attributes") or lang
return int(attrs.get("id", 1))
def all_products():
data = api_get("products", params={
"display": "[id,id_default_image,link_rewrite,name,date_add]",
"limit": "0",
})
return data.get("products") or []
def flatten_by_lang(raw_products):
by_lang = {}
for item in raw_products:
slug_entries = {_lang_id(e): e.get("#text", "") for e in _entries(item.get("link_rewrite"))}
name_entries = {_lang_id(e): e.get("#text", "") for e in _entries(item.get("name"))}
for id_lang, slug in slug_entries.items():
by_lang.setdefault(id_lang, []).append({
"id": int(item["id"]),
"link_rewrite": slug,
"name": name_entries.get(id_lang, ""),
"date_add": item.get("date_add", ""),
})
return by_lang
def apply_rename(product_id, new_slug, id_lang):
full = api_get(f"products/{product_id}")
node = full["product"]
entries = node["link_rewrite"]
if isinstance(entries, dict):
entries = [entries]
for entry in entries:
lang = entry.get("language") or {}
attrs = lang.get("@attributes") or lang
if int(attrs.get("id", 1)) == id_lang:
entry["#text"] = new_slug
node["link_rewrite"] = entries
r = requests.put(
f"{PRESTASHOP_URL}/api/products/{product_id}",
params={"output_format": "JSON"},
json=full,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
def run():
raw_products = all_products()
by_lang = flatten_by_lang(raw_products)
by_id = {p["id"]: p for p in raw_products}
fixed = 0
flagged = 0
for id_lang, products in by_lang.items():
by_product_id = {p["id"]: p for p in products}
changes = suffix_duplicate_slugs(products, id_lang)
for change in changes:
dup = by_product_id[change["id"]]
original_group = [p for p in products if p["link_rewrite"] == change["old_slug"]]
original = min(original_group, key=lambda p: (p["date_add"] or "", p["id"]))
if names_diverged(original["name"], dup["name"]):
log.warning(
"id_lang=%s id=%s old_slug=%s name=%r diverged from original name=%r. Flagging for a human.",
id_lang, change["id"], change["old_slug"], dup["name"], original["name"],
)
flagged += 1
continue
log.warning(
"id_lang=%s id=%s old_slug=%s %s new_slug=%s",
id_lang, change["id"], change["old_slug"],
"would rename to" if DRY_RUN else "renaming to", change["new_slug"],
)
if not DRY_RUN:
apply_rename(change["id"], change["new_slug"], id_lang)
fixed += 1
log.info(
"Done. %d slug(s) %s, %d flagged for a human. DRY_RUN=%s.",
fixed, "to rename" if DRY_RUN else "renamed", flagged, DRY_RUN,
)
if __name__ == "__main__":
run()
/**
* Find and safely fix PrestaShop products whose friendly URL was copied
* verbatim from the product they were duplicated from.
*
* PrestaShop's Duplicate action, backed by ObjectModel::duplicateObject()
* and Product::duplicate(), clones the source product's rows with a raw
* INSERT ... SELECT copy, including every ps_product_lang row, then only
* rewrites id_product and a few flags such as active. The friendly URL slug
* in link_rewrite is only ever regenerated from the product name inside
* AdminProductsController's form-save path, using Tools::link_rewrite() or
* str2url(), triggered when the Name field is actually edited and saved.
* The duplication flow never runs that path, so the clone keeps the
* identical link_rewrite as the original in every language. Because the
* product URL is unique on id_product plus link_rewrite together, not on
* link_rewrite alone, no SQL error is raised: both products stay reachable,
* and the collision only shows up as visually identical URLs, canonical
* confusion, and duplicate content signals to search engines.
*
* This script detects every collision, keeps the earliest product in each
* group (by date_add) as the canonical original, and proposes a
* deterministic new slug for every later duplicate. It skips any duplicate
* whose name has diverged significantly from the original, since that
* usually means a human already turned the copy into its own product and
* just never touched its slug, a case that should be flagged for a human to
* rename from the SEO tab instead of guessed automatically. Writing is
* guarded by DRY_RUN, which defaults to true, since a rename changes a
* public URL.
*
* Run after any bulk duplication session, or on a nightly schedule.
*
* Guide: https://www.allanninal.dev/prestashop/duplicated-product-keeps-original-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";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* products is an array of {id, link_rewrite, name, date_add} already
* resolved to a single language (idLang is kept only for readability/logging
* at the call site). Groups records by link_rewrite; any group with more
* than one member is a collision. The earliest member by date_add (falling
* back to id) is kept unchanged, and every other member gets
* new_slug = `${old_slug}-${id}`, extended with a "-dup" suffix if that
* candidate still collides with any other slug already present in the full
* product set, including another group's own repair. Returns only the
* changed entries.
*/
export function suffixDuplicateSlugs(products, idLang) {
const groups = new Map();
for (const p of products) {
if (!groups.has(p.link_rewrite)) groups.set(p.link_rewrite, []);
groups.get(p.link_rewrite).push(p);
}
const allSlugs = new Set(products.map((p) => p.link_rewrite));
const changes = [];
for (const [slug, members] of groups) {
if (members.length < 2) continue;
const ordered = [...members].sort((a, b) => {
const da = a.date_add || "";
const db = b.date_add || "";
if (da !== db) return da < db ? -1 : 1;
return a.id - b.id;
});
for (const p of ordered.slice(1)) {
let candidate = `${slug}-${p.id}`;
while (allSlugs.has(candidate)) candidate = `${candidate}-dup`;
allSlugs.add(candidate);
changes.push({ id: p.id, old_slug: slug, new_slug: candidate });
}
}
return changes;
}
/**
* True when the duplicate's name no longer resembles the original's,
* meaning the slug should only be flagged for a human, never auto-fixed.
*/
export function namesDiverged(originalName, duplicateName) {
const a = (originalName || "").trim().toLowerCase();
const b = (duplicateName || "").trim().toLowerCase();
if (!a || !b) return true;
return !a.includes(b) && !b.includes(a);
}
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();
}
function entries(value) {
if (value == null) return [];
return Array.isArray(value) ? value : [value];
}
function langId(entry) {
const lang = entry.language || {};
const attrs = lang["@attributes"] || lang;
return Number(attrs.id ?? 1);
}
async function allProducts() {
const data = await apiGet("products", {
display: "[id,id_default_image,link_rewrite,name,date_add]",
limit: "0",
});
return data.products || [];
}
function flattenByLang(rawProducts) {
const byLang = {};
for (const item of rawProducts) {
const slugEntries = new Map(entries(item.link_rewrite).map((e) => [langId(e), e["#text"] || ""]));
const nameEntries = new Map(entries(item.name).map((e) => [langId(e), e["#text"] || ""]));
for (const [idLang, slug] of slugEntries) {
if (!byLang[idLang]) byLang[idLang] = [];
byLang[idLang].push({
id: Number(item.id),
link_rewrite: slug,
name: nameEntries.get(idLang) || "",
date_add: item.date_add || "",
});
}
}
return byLang;
}
async function applyRename(productId, newSlug, idLang) {
const full = await apiGet(`products/${productId}`);
const node = full.product;
let entries2 = node.link_rewrite;
if (!Array.isArray(entries2)) entries2 = [entries2];
for (const entry of entries2) {
const lang = entry.language || {};
const attrs = lang["@attributes"] || lang;
if (Number(attrs.id ?? 1) === idLang) entry["#text"] = newSlug;
}
node.link_rewrite = entries2;
const url = new URL(`${PRESTASHOP_URL}/api/products/${productId}`);
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 products/${productId}`);
}
export async function run() {
const rawProducts = await allProducts();
const byLang = flattenByLang(rawProducts);
let fixed = 0;
let flagged = 0;
for (const [idLangKey, products] of Object.entries(byLang)) {
const idLang = Number(idLangKey);
const byProductId = new Map(products.map((p) => [p.id, p]));
const changes = suffixDuplicateSlugs(products, idLang);
for (const change of changes) {
const dup = byProductId.get(change.id);
const originalGroup = products.filter((p) => p.link_rewrite === change.old_slug);
const original = originalGroup.reduce((best, p) => {
const bd = best.date_add || "";
const pd = p.date_add || "";
if (pd !== bd) return pd < bd ? p : best;
return p.id < best.id ? p : best;
});
if (namesDiverged(original.name, dup.name)) {
console.warn(
`id_lang=${idLang} id=${change.id} old_slug=${change.old_slug} name=${JSON.stringify(dup.name)} ` +
`diverged from original name=${JSON.stringify(original.name)}. Flagging for a human.`
);
flagged++;
continue;
}
console.warn(
`id_lang=${idLang} id=${change.id} old_slug=${change.old_slug} ` +
`${DRY_RUN ? "would rename to" : "renaming to"} new_slug=${change.new_slug}`
);
if (!DRY_RUN) await applyRename(change.id, change.new_slug, idLang);
fixed++;
}
}
console.log(`Done. ${fixed} slug(s) ${DRY_RUN ? "to rename" : "renamed"}, ${flagged} flagged for a human. 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 product keeps its slug and what every duplicate's new slug becomes. Because we kept suffix_duplicate_slugs pure, the test needs no network and no PrestaShop store. It just feeds in plain records and checks the answer.
from fix_duplicated_product_slug import suffix_duplicate_slugs, names_diverged
def product(**over):
base = {"id": 1, "link_rewrite": "desktop-computer", "name": "Desktop Computer", "date_add": "2026-01-01 00:00:00"}
base.update(over)
return base
def test_no_changes_when_all_slugs_unique():
rows = [
product(id=1, link_rewrite="desktop-computer"),
product(id=2, link_rewrite="laptop-computer"),
]
assert suffix_duplicate_slugs(rows, 1) == []
def test_duplicate_keeps_earliest_and_suffixes_the_rest():
rows = [
product(id=1, link_rewrite="desktop-computer", date_add="2026-01-01 00:00:00"),
product(id=7, link_rewrite="desktop-computer", date_add="2026-02-15 00:00:00"),
]
changes = suffix_duplicate_slugs(rows, 1)
assert changes == [{"id": 7, "old_slug": "desktop-computer", "new_slug": "desktop-computer-7"}]
def test_falls_back_to_id_when_date_add_ties():
rows = [
product(id=5, link_rewrite="desktop-computer", date_add="2026-01-01 00:00:00"),
product(id=2, link_rewrite="desktop-computer", date_add="2026-01-01 00:00:00"),
]
changes = suffix_duplicate_slugs(rows, 1)
assert changes == [{"id": 5, "old_slug": "desktop-computer", "new_slug": "desktop-computer-5"}]
def test_appends_dup_when_suffixed_candidate_already_taken():
rows = [
product(id=1, link_rewrite="desktop-computer", date_add="2026-01-01 00:00:00"),
product(id=9, link_rewrite="desktop-computer", date_add="2026-02-01 00:00:00"),
product(id=99, link_rewrite="desktop-computer-9", date_add="2026-01-05 00:00:00"),
]
changes = suffix_duplicate_slugs(rows, 1)
assert {"id": 9, "old_slug": "desktop-computer", "new_slug": "desktop-computer-9-dup"} in changes
def test_three_way_collision_keeps_earliest_and_suffixes_the_rest():
rows = [
product(id=3, link_rewrite="office-chair", date_add="2026-03-01 00:00:00"),
product(id=1, link_rewrite="office-chair", date_add="2026-01-01 00:00:00"),
product(id=2, link_rewrite="office-chair", date_add="2026-02-01 00:00:00"),
]
changes = suffix_duplicate_slugs(rows, 1)
assert sorted(c["id"] for c in changes) == [2, 3]
def test_names_diverged_true_when_unrelated():
assert names_diverged("Desktop Computer", "Garden Hose") is True
def test_names_diverged_false_when_still_similar():
assert names_diverged("Desktop Computer", "Desktop Computer V2") is False
def test_names_diverged_true_when_either_name_missing():
assert names_diverged("", "Desktop Computer") is True
assert names_diverged("Desktop Computer", "") is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { suffixDuplicateSlugs, namesDiverged } from "./fix-duplicated-product-slug.js";
const product = (over = {}) => ({
id: 1,
link_rewrite: "desktop-computer",
name: "Desktop Computer",
date_add: "2026-01-01 00:00:00",
...over,
});
test("no changes when all slugs unique", () => {
const rows = [
product({ id: 1, link_rewrite: "desktop-computer" }),
product({ id: 2, link_rewrite: "laptop-computer" }),
];
assert.deepEqual(suffixDuplicateSlugs(rows, 1), []);
});
test("duplicate keeps earliest and suffixes the rest", () => {
const rows = [
product({ id: 1, link_rewrite: "desktop-computer", date_add: "2026-01-01 00:00:00" }),
product({ id: 7, link_rewrite: "desktop-computer", date_add: "2026-02-15 00:00:00" }),
];
const changes = suffixDuplicateSlugs(rows, 1);
assert.deepEqual(changes, [{ id: 7, old_slug: "desktop-computer", new_slug: "desktop-computer-7" }]);
});
test("falls back to id when date_add ties", () => {
const rows = [
product({ id: 5, link_rewrite: "desktop-computer", date_add: "2026-01-01 00:00:00" }),
product({ id: 2, link_rewrite: "desktop-computer", date_add: "2026-01-01 00:00:00" }),
];
const changes = suffixDuplicateSlugs(rows, 1);
assert.deepEqual(changes, [{ id: 5, old_slug: "desktop-computer", new_slug: "desktop-computer-5" }]);
});
test("appends -dup when suffixed candidate already taken", () => {
const rows = [
product({ id: 1, link_rewrite: "desktop-computer", date_add: "2026-01-01 00:00:00" }),
product({ id: 9, link_rewrite: "desktop-computer", date_add: "2026-02-01 00:00:00" }),
product({ id: 99, link_rewrite: "desktop-computer-9", date_add: "2026-01-05 00:00:00" }),
];
const changes = suffixDuplicateSlugs(rows, 1);
assert.ok(changes.some((c) => c.id === 9 && c.new_slug === "desktop-computer-9-dup"));
});
test("three way collision keeps earliest and suffixes the rest", () => {
const rows = [
product({ id: 3, link_rewrite: "office-chair", date_add: "2026-03-01 00:00:00" }),
product({ id: 1, link_rewrite: "office-chair", date_add: "2026-01-01 00:00:00" }),
product({ id: 2, link_rewrite: "office-chair", date_add: "2026-02-01 00:00:00" }),
];
const changes = suffixDuplicateSlugs(rows, 1);
assert.deepEqual(changes.map((c) => c.id).sort(), [2, 3]);
});
test("namesDiverged true when unrelated", () => {
assert.equal(namesDiverged("Desktop Computer", "Garden Hose"), true);
});
test("namesDiverged false when still similar", () => {
assert.equal(namesDiverged("Desktop Computer", "Desktop Computer V2"), false);
});
test("namesDiverged true when either name missing", () => {
assert.equal(namesDiverged("", "Desktop Computer"), true);
assert.equal(namesDiverged("Desktop Computer", ""), true);
});
Case studies
Twelve colorways, one slug
A homeware store duplicated its bestselling desk lamp eleven times to create a colorway for every finish they stocked, editing the price and image on each copy but leaving the name and SEO tab alone. All twelve products carried the exact same link_rewrite, so search engines only ever indexed one of them and internal links from marketing emails kept landing on whichever product happened to load first.
The nightly job found the group of twelve, kept the earliest as the canonical desk-lamp, and suffixed the rest with their own id. Every colorway now has its own crawlable, bookmarkable URL, and the report showed nothing needed a human's judgment since all twelve names still clearly matched the original.
The copy that became a different product
A merchandiser duplicated a phone case to start a new leather version, then spent a week renaming it, changing the description, and swapping every image, but forgot the SEO tab still read the original plastic case's slug. The script's grouping caught the collision immediately.
Because the new name no longer resembled the original closely enough, names_diverged returned true and the script only flagged it instead of guessing a slug from an unrelated name. A quick look at the SEO tab and a real slug, not an autogenerated -id suffix, was the right fix, exactly the outcome the flag was built for.
After this runs on a schedule, every duplicated product either keeps its own distinct, crawlable URL or gets flagged for the ten seconds it takes a human to type a real slug. Search engines stop seeing duplicate content, canonical tags stop fighting each other, and nobody has to remember to check the SEO tab by hand after every Duplicate click.
FAQ
Why does a duplicated PrestaShop product keep the same friendly URL as the original?
The Duplicate action clones the source product's rows with a raw INSERT ... SELECT copy, including every ps_product_lang row, then only rewrites id_product and a few flags such as active. PrestaShop only regenerates link_rewrite from the product name inside the admin form's save path, which the duplication flow never runs, so the clone keeps the exact same slug as the original in every language.
Why does PrestaShop not raise an error when two products share the same link_rewrite?
PrestaShop resolves a product's friendly URL using id_product plus link_rewrite together as the unique key, not link_rewrite alone. Both rows stay individually reachable through their own id_product, so no unique-index violation is thrown at write time. The collision only shows up as visually identical URLs, canonical confusion, and duplicate-content signals to search engines.
Is it safe to auto-fix a duplicated product's slug?
It is safe when the duplicate's name still closely matches the original, since that confirms the slug was simply never touched after duplication. When the name has diverged significantly, the product is likely a deliberate, already-differentiated product whose slug was just never renamed, and guessing a new slug from an unrelated name risks a nonsensical URL, so that case should only be flagged for a human to rename from the SEO tab.
Related field notes
Citations
On the problem:
- PrestaShop Forums: Duplicating a Product, Friendly URL does not update or change. prestashop.com/forums/topic/76870
- PrestaShop Forums: Duplicated Friendly Url (link_rewrite) Issue. prestashop.com/forums/topic/225091
- PrestaShop GitHub: Canonical redirects for product pages do not work, gives duplicate content, issue #18299. github.com/PrestaShop/PrestaShop/issues/18299
On the solution:
- PrestaShop Developer Documentation: Products webservice resource reference. devdocs.prestashop-project.org/9/webservice/resources/products
- PrestaShop Developer Documentation: The Webservice, getting started, authentication, and output_format. devdocs.prestashop-project.org/8/webservice/getting-started
- PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/8/webservice/tutorials/create-product-az
Stuck on a tricky one?
If you have a problem in PrestaShop catalog, orders, stock, 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 clear up your duplicate slugs?
If this saved you a search console warning or a confusing 404, 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