Reconciler
Friendly URL field accepts invalid values instead of a proper slug
Someone posted a product through the webservice, and the row saved fine. Weeks later the product page 404s, or opening it in the back office throws a validation error the moment you hit save. Whatever wrote the link_rewrite field never went through PrestaShop's own slug check, and the API let it through anyway. Here is why that gap exists and a script that finds every slug that is not actually a slug and safely repairs it.
PrestaShop is supposed to enforce that link_rewrite matches Validate::isLinkRewrite(), a regex of only [_a-zA-Z0-9-], or that same set plus accented letters when PS_ALLOW_ACCENTED_CHARS_URL is on. But the webservice does not run that check consistently across every write path, and in some PS8 releases Tools::str2url(), the helper meant to slugify a free-text title, has stopped stripping disallowed characters. So a caller that posts a raw title, a full URL, or Unicode punctuation can land it straight in the link_rewrite column. Run a Python or Node.js script that pulls every product, category, manufacturer, and CMS page link_rewrite per language through the webservice, tests each value against the same regex PrestaShop itself uses, and reports every id, language, and offending value it finds. Full code, tests, and a dry run guarded repair are below.
The problem in plain words
A friendly URL slug only works if it is made of characters a URL path segment and an .htaccess rewrite rule can handle safely, letters, digits, underscores, and hyphens. PrestaShop stores that value in link_rewrite, one entry per language, on products, categories, manufacturers, and CMS pages.
The back-office edit form runs the value through Validate::isLinkRewrite() before it lets you save, and Tools::str2url() is supposed to turn any free-text title into something that already passes that check. But the webservice does not apply the same rule on every request. A POST that creates a product can accept a value like a full domain name outright, because the strict check that the back-office controller runs is not always the same check the webservice API path runs. The row saves. Nothing complains, until the SEO URL resolver tries to build a link out of a value that contains dots or slashes, and the product or category page starts returning a 404 even though the database row itself looks perfectly normal.
Why it happens
The root cause is that link_rewrite validation is not applied the same way on every code path that can write it. Documented ways it shows up:
- PrestaShop's webservice validates most product fields strictly, but reported bugs, GitHub issue #13151 among them, show the API accepting a value like
abc.comon product creation, because validation runs inconsistently acrossPOSTandPUTversus the back-officeObjectModel::validateFields()flow that the admin controller calls. - The bad value only starts causing visible trouble when the row is re-saved through the back office form, which does run
isLinkRewrite()and rejects it, so the first sign of the problem can be an editor unable to save a product that has been live and broken for weeks. Tools::str2url(), also known asTools::link_rewrite(), is supposed to slugify any free-text title into a safe value automatically. GitHub issue #38161 documents that in some PrestaShop 8 releases it stopped stripping every disallowed character, so a caller that relies on it to sanitize a title can still end up with Unicode punctuation or other stray characters in the stored slug.- Brands and suppliers are not exempt either, GitHub issue #27716 shows the same class of problem reaching manufacturer link_rewrite values through the webservice.
- Because the value is stored per language, a product can look perfectly valid in one language and carry a broken slug in another, which stays invisible until a shopper switches languages.
None of this shows up as an error banner anywhere in the back office once the row exists. The record edits normally in every field except the one that is broken, and the only visible symptom is a 404 on the storefront, or a save that suddenly fails once someone touches the record through the form. See the citations at the end for the exact issues.
Fixing an invalid slug is not a guess, PrestaShop already defines exactly what a valid one looks like: Validate::isLinkRewrite()'s own regex. So the safe pattern is to run every stored link_rewrite value back through that same rule, not a looser approximation of it, and to only replace a failing value with a fresh slug built from the record's own name, the same source Tools::str2url() is meant to use. And because a slug is a public URL, treat every replacement 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 localized-slug-bearing resource through the webservice, tests each language's link_rewrite against PrestaShop's own regex, and reports any value that fails. For each flagged value, a candidate replacement is slugified from the record's own name in that language, and a guarded write can apply it.
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, categories, manufacturers, and content_management_system, 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
Check whether accented URLs are allowed
PrestaShop's allowed character set for link_rewrite widens when the store enables accented URLs. Read PS_ALLOW_ACCENTED_CHARS_URL once at the start so every value is tested against the exact rule the store itself enforces, not a stricter or looser guess.
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 accented_urls_allowed():
data = api_get("configurations", params={"filter[name]": "PS_ALLOW_ACCENTED_CHARS_URL"})
rows = data.get("configurations") or []
if not rows:
return False
return str(rows[0].get("value", "0")) == "1"
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 accentedUrlsAllowed() {
const data = await apiGet("configurations", { "filter[name]": "PS_ALLOW_ACCENTED_CHARS_URL" });
const rows = data.configurations || [];
if (!rows.length) return false;
return String(rows[0].value ?? "0") === "1";
}
Pull every localized slug and flatten it
Call GET /api/products?display=full&output_format=JSON&limit=0, and repeat with categories, manufacturers, and content_management_system in place of products. Each item's link_rewrite and name come back as arrays keyed by language, so unwrap both into one flat record per (resource, id, id_lang, link_rewrite, name), which is exactly the shape the pure validator expects.
RESOURCES = ["products", "categories", "manufacturers", "content_management_system"]
def _by_lang(entries):
if isinstance(entries, dict):
entries = [entries]
out = {}
for entry in entries or []:
lang = entry.get("language") or {}
id_lang = int(lang.get("@id", lang.get("id", 1)))
out[id_lang] = entry.get("value", entry.get("#text", ""))
return out
def flatten_resource(resource, raw_items):
records = []
for item in raw_items:
slugs = _by_lang(item.get("link_rewrite"))
names = _by_lang(item.get("name") or item.get("meta_title"))
for id_lang, slug in slugs.items():
records.append({
"resource": resource,
"id": int(item["id"]),
"id_lang": id_lang,
"link_rewrite": slug,
"name": names.get(id_lang, ""),
})
return records
def collect_records():
all_records = []
for resource in RESOURCES:
data = api_get(resource, params={"display": "full", "limit": "0"})
raw_items = data.get(resource) or []
all_records.extend(flatten_resource(resource, raw_items))
return all_records
const RESOURCES = ["products", "categories", "manufacturers", "content_management_system"];
function byLang(entries) {
if (!Array.isArray(entries)) entries = entries ? [entries] : [];
const out = {};
for (const entry of entries) {
const lang = entry.language || {};
const idLang = Number(lang["@id"] ?? lang.id ?? 1);
out[idLang] = entry.value ?? entry["#text"] ?? "";
}
return out;
}
function flattenResource(resource, rawItems) {
const records = [];
for (const item of rawItems) {
const slugs = byLang(item.link_rewrite);
const names = byLang(item.name || item.meta_title);
for (const [idLangStr, slug] of Object.entries(slugs)) {
const idLang = Number(idLangStr);
records.push({
resource,
id: Number(item.id),
id_lang: idLang,
link_rewrite: slug,
name: names[idLang] || "",
});
}
}
return records;
}
async function collectRecords() {
const all = [];
for (const resource of RESOURCES) {
const data = await apiGet(resource, { display: "full", limit: "0" });
const rawItems = data[resource] || [];
all.push(...flattenResource(resource, rawItems));
}
return all;
}
Decide, with one pure function
Keep the validity check in its own function that takes a value and whether accented URLs are allowed, and returns true or false. It mirrors Validate::isLinkRewrite() exactly, plus an explicit rejection of empty strings and of dots, slashes, colons, and whitespace even in the accented mode, since those characters have no business in a slug regardless of what the base regex would technically admit.
import re
_PLAIN = re.compile(r"^[_a-zA-Z0-9\-]+$")
_ACCENTED = re.compile(r"^[_a-zA-Z0-9\-\w]+$", re.UNICODE)
_DISALLOWED_CHARS = (" ", ".", "/", ":", "\\")
def is_valid_slug(value, allow_accented=False):
if not value or any(c in value for c in _DISALLOWED_CHARS):
return False
pattern = _ACCENTED if allow_accented else _PLAIN
return bool(pattern.match(value))
const PLAIN = /^[_a-zA-Z0-9-]+$/;
const ACCENTED = /^[_a-zA-Z0-9\-\p{L}\p{S}]+$/u;
const DISALLOWED_CHARS = [" ", ".", "/", ":", "\\"];
export function isValidSlug(value, allowAccented = false) {
if (!value || DISALLOWED_CHARS.some((c) => value.includes(c))) return false;
const pattern = allowAccented ? ACCENTED : PLAIN;
return pattern.test(value);
}
Slugify the record's own name as the candidate fix
When a value fails the check, build a replacement from the same source Tools::str2url() is meant to use, the record's own name in that language. Lowercase it, strip accents to plain letters, replace anything that is not a letter, digit, underscore, or hyphen with a single hyphen, and trim stray hyphens from the ends.
import unicodedata
def slugify(name):
normalized = unicodedata.normalize("NFKD", name or "")
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
lowered = ascii_only.lower()
slug = re.sub(r"[^a-z0-9_-]+", "-", lowered).strip("-")
return slug or "untitled"
export function slugify(name) {
const normalized = (name || "").normalize("NFKD").replace(/[̀-ͯ]/g, "");
const lowered = normalized.toLowerCase();
const slug = lowered.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
return slug || "untitled";
}
Apply the repair with a full-resource PUT, and wire it together
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 language, then send the whole object back with PUT /api/{resource}/{id}, and re-GET to confirm the stored value now passes is_valid_slug. Leave DRY_RUN on for the first runs, read the planned repairs, agree with them, and remember that PrestaShop can set up a 301 redirect for a changed product URL automatically, so old links do not 404 once you flip DRY_RUN to false.
Always start with DRY_RUN=true. A repaired slug changes a public URL, so before writing for real, turn on the "Set up a 301 redirect when the URL is changed" preference in Preferences, SEO & URLs, otherwise existing inbound links, bookmarks, and search engine index entries break.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pulls every localized-slug-bearing resource, tests each value against PrestaShop's own regex, and repairs every invalid value behind the dry run guard, re-checking the stored value afterward.
"""Find and safely repair PrestaShop link_rewrite values that are not a valid slug.
PrestaShop's webservice layer validates most product fields strictly, but it does
not consistently run link_rewrite through Validate::isLinkRewrite() on every write
path. Reported bugs, such as GitHub issue #13151, show the API accepting a value
like "abc.com" on product creation, because validation runs inconsistently across
POST and PUT versus the back-office ObjectModel::validateFields() flow, and the
row only starts failing when it is re-saved through the admin form. Separately,
Tools::str2url(), meant to slugify a free-text title into a safe link_rewrite, has
in some PS8 releases stopped stripping every disallowed character (GitHub issue
#38161), so a caller that skips slugification and posts a raw title, a full URL,
or Unicode punctuation can land it directly in the link_rewrite column. Because
.htaccess rewrite rules and the SEO URL resolver assume link_rewrite is a clean
slug, a stored value containing dots, slashes, spaces, or scheme-like text breaks
canonical URL generation and can 404 the page even though the row saved fine.
This script pulls every product, category, manufacturer, and CMS page
link_rewrite per language through the webservice, tests each value against the
same regex PrestaShop itself enforces (Validate::isLinkRewrite(), widened for
accented characters when PS_ALLOW_ACCENTED_CHARS_URL is on), and reports every
value that fails. Repairing is guarded by DRY_RUN, which defaults to true, since
a repair changes a public URL. Turn on PrestaShop's own 301 redirect preference
for changed product URLs before running with DRY_RUN=false.
Run on a schedule, or right after any bulk import or webservice write job. Safe
to run again and again: an already-valid slug is never touched.
Guide: https://www.allanninal.dev/prestashop/invalid-friendly-url-format-accepted/
"""
import os
import re
import logging
import unicodedata
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_invalid_friendly_url_format")
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, "")
RESOURCES = ["products", "categories", "manufacturers", "content_management_system"]
_PLAIN = re.compile(r"^[_a-zA-Z0-9\-]+$")
_ACCENTED = re.compile(r"^[_a-zA-Z0-9\-\w]+$", re.UNICODE)
_DISALLOWED_CHARS = (" ", ".", "/", ":", "\\")
def is_valid_slug(value, allow_accented=False):
"""Pure decision function, no I/O.
Mirrors PrestaShop's own Validate::isLinkRewrite(): letters, digits,
underscores, and hyphens only, or that same set plus accented word
characters when allow_accented is True. Additionally rejects empty
strings and any value containing a space, dot, slash, colon, or
backslash even when the base regex would otherwise admit it, since
none of those characters belong in a slug.
"""
if not value or any(c in value for c in _DISALLOWED_CHARS):
return False
pattern = _ACCENTED if allow_accented else _PLAIN
return bool(pattern.match(value))
def slugify(name):
normalized = unicodedata.normalize("NFKD", name or "")
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
lowered = ascii_only.lower()
slug = re.sub(r"[^a-z0-9_-]+", "-", lowered).strip("-")
return slug or "untitled"
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 api_put(path, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def accented_urls_allowed():
data = api_get("configurations", params={"filter[name]": "PS_ALLOW_ACCENTED_CHARS_URL"})
rows = data.get("configurations") or []
if not rows:
return False
return str(rows[0].get("value", "0")) == "1"
def _by_lang(entries):
if isinstance(entries, dict):
entries = [entries]
out = {}
for entry in entries or []:
lang = entry.get("language") or {}
id_lang = int(lang.get("@id", lang.get("id", 1)))
out[id_lang] = entry.get("value", entry.get("#text", ""))
return out
def flatten_resource(resource, raw_items):
records = []
for item in raw_items:
slugs = _by_lang(item.get("link_rewrite"))
names = _by_lang(item.get("name") or item.get("meta_title"))
for id_lang, slug in slugs.items():
records.append({
"resource": resource,
"id": int(item["id"]),
"id_lang": id_lang,
"link_rewrite": slug,
"name": names.get(id_lang, ""),
})
return records
def collect_records():
all_records = []
for resource in RESOURCES:
data = api_get(resource, params={"display": "full", "limit": "0"})
raw_items = data.get(resource) or []
all_records.extend(flatten_resource(resource, raw_items))
return all_records
def apply_repair(record, candidate):
resource = record["resource"]
full = api_get(f"{resource}/{record['id']}")
singular = resource[:-1] if resource != "content_management_system" else "content_management_system"
node = full[singular]
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))) == record["id_lang"]:
entry["value"] = candidate
node["link_rewrite"] = entries
api_put(f"{resource}/{record['id']}", full)
confirm = api_get(f"{resource}/{record['id']}")
confirm_entries = confirm[singular]["link_rewrite"]
if isinstance(confirm_entries, dict):
confirm_entries = [confirm_entries]
for entry in confirm_entries:
lang = entry.get("language") or {}
if int(lang.get("@id", lang.get("id", 1))) == record["id_lang"]:
stored = entry.get("value", entry.get("#text", ""))
if stored != candidate:
raise RuntimeError(f"Repair did not stick for {resource}/{record['id']}: {stored!r}")
def run():
allow_accented = accented_urls_allowed()
records = collect_records()
fixed = 0
for record in records:
if is_valid_slug(record["link_rewrite"], allow_accented):
continue
candidate = slugify(record["name"])
log.warning(
"Invalid link_rewrite. resource=%s id=%s id_lang=%s old=%r %s new=%r",
record["resource"], record["id"], record["id_lang"], record["link_rewrite"],
"would set" if DRY_RUN else "setting", candidate,
)
if not DRY_RUN:
apply_repair(record, candidate)
log.info(
"Fixed %s/%s. Confirm the 301 redirect preference is on so old links do not 404.",
record["resource"], record["id"],
)
fixed += 1
log.info("Done. %d slug(s) %s. DRY_RUN=%s.", fixed, "to fix" if DRY_RUN else "fixed", DRY_RUN)
if __name__ == "__main__":
run()
/**
* Find and safely repair PrestaShop link_rewrite values that are not a valid slug.
*
* PrestaShop's webservice layer validates most product fields strictly, but it
* does not consistently run link_rewrite through Validate::isLinkRewrite() on
* every write path. Reported bugs, such as GitHub issue #13151, show the API
* accepting a value like "abc.com" on product creation, because validation runs
* inconsistently across POST and PUT versus the back-office
* ObjectModel::validateFields() flow, and the row only starts failing when it
* is re-saved through the admin form. Separately, Tools::str2url(), meant to
* slugify a free-text title into a safe link_rewrite, has in some PS8 releases
* stopped stripping every disallowed character (GitHub issue #38161), so a
* caller that skips slugification and posts a raw title, a full URL, or
* Unicode punctuation can land it directly in the link_rewrite column. Because
* .htaccess rewrite rules and the SEO URL resolver assume link_rewrite is a
* clean slug, a stored value containing dots, slashes, spaces, or scheme-like
* text breaks canonical URL generation and can 404 the page even though the
* row saved fine.
*
* This script pulls every product, category, manufacturer, and CMS page
* link_rewrite per language through the webservice, tests each value against
* the same regex PrestaShop itself enforces (Validate::isLinkRewrite(), widened
* for accented characters when PS_ALLOW_ACCENTED_CHARS_URL is on), and reports
* every value that fails. Repairing is guarded by DRY_RUN, which defaults to
* true, since a repair changes a public URL. Turn on PrestaShop's own 301
* redirect preference for changed product URLs before running with
* DRY_RUN=false.
*
* Run on a schedule, or right after any bulk import or webservice write job.
*
* Guide: https://www.allanninal.dev/prestashop/invalid-friendly-url-format-accepted/
*/
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 RESOURCES = ["products", "categories", "manufacturers", "content_management_system"];
const PLAIN = /^[_a-zA-Z0-9-]+$/;
const ACCENTED = /^[_a-zA-Z0-9\-\p{L}\p{S}]+$/u;
const DISALLOWED_CHARS = [" ", ".", "/", ":", "\\"];
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* Mirrors PrestaShop's own Validate::isLinkRewrite(): letters, digits,
* underscores, and hyphens only, or that same set plus accented word
* characters when allowAccented is true. Additionally rejects empty strings
* and any value containing a space, dot, slash, colon, or backslash even when
* the base regex would otherwise admit it, since none of those characters
* belong in a slug.
*/
export function isValidSlug(value, allowAccented = false) {
if (!value || DISALLOWED_CHARS.some((c) => value.includes(c))) return false;
const pattern = allowAccented ? ACCENTED : PLAIN;
return pattern.test(value);
}
export function slugify(name) {
const normalized = (name || "").normalize("NFKD").replace(/[̀-ͯ]/g, "");
const lowered = normalized.toLowerCase();
const slug = lowered.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
return slug || "untitled";
}
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 apiPut(path, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function accentedUrlsAllowed() {
const data = await apiGet("configurations", { "filter[name]": "PS_ALLOW_ACCENTED_CHARS_URL" });
const rows = data.configurations || [];
if (!rows.length) return false;
return String(rows[0].value ?? "0") === "1";
}
function byLang(entries) {
if (!Array.isArray(entries)) entries = entries ? [entries] : [];
const out = {};
for (const entry of entries) {
const lang = entry.language || {};
const idLang = Number(lang["@id"] ?? lang.id ?? 1);
out[idLang] = entry.value ?? entry["#text"] ?? "";
}
return out;
}
function flattenResource(resource, rawItems) {
const records = [];
for (const item of rawItems) {
const slugs = byLang(item.link_rewrite);
const names = byLang(item.name || item.meta_title);
for (const [idLangStr, slug] of Object.entries(slugs)) {
const idLang = Number(idLangStr);
records.push({
resource,
id: Number(item.id),
id_lang: idLang,
link_rewrite: slug,
name: names[idLang] || "",
});
}
}
return records;
}
async function collectRecords() {
const all = [];
for (const resource of RESOURCES) {
const data = await apiGet(resource, { display: "full", limit: "0" });
const rawItems = data[resource] || [];
all.push(...flattenResource(resource, rawItems));
}
return all;
}
async function applyRepair(record, candidate) {
const resource = record.resource;
const full = await apiGet(`${resource}/${record.id}`);
const singular = resource === "content_management_system" ? "content_management_system" : 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) === record.id_lang) entry.value = candidate;
}
node.link_rewrite = entries;
await apiPut(`${resource}/${record.id}`, full);
const confirm = await apiGet(`${resource}/${record.id}`);
let confirmEntries = confirm[singular].link_rewrite;
if (!Array.isArray(confirmEntries)) confirmEntries = [confirmEntries];
for (const entry of confirmEntries) {
const lang = entry.language || {};
if (Number(lang["@id"] ?? lang.id ?? 1) === record.id_lang) {
const stored = entry.value ?? entry["#text"] ?? "";
if (stored !== candidate) {
throw new Error(`Repair did not stick for ${resource}/${record.id}: ${stored}`);
}
}
}
}
export async function run() {
const allowAccented = await accentedUrlsAllowed();
const records = await collectRecords();
let fixed = 0;
for (const record of records) {
if (isValidSlug(record.link_rewrite, allowAccented)) continue;
const candidate = slugify(record.name);
console.warn(
`Invalid link_rewrite. resource=${record.resource} id=${record.id} id_lang=${record.id_lang} ` +
`old=${JSON.stringify(record.link_rewrite)} ${DRY_RUN ? "would set" : "setting"} new=${JSON.stringify(candidate)}`
);
if (!DRY_RUN) {
await applyRepair(record, candidate);
console.log(
`Fixed ${record.resource}/${record.id}. Confirm the 301 redirect preference is on so old links do not 404.`
);
}
fixed++;
}
console.log(`Done. ${fixed} slug(s) ${DRY_RUN ? "to fix" : "fixed"}. 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 slug validity check is the part most worth testing, because it decides which values get rewritten. Because we kept is_valid_slug pure, the test needs no network and no PrestaShop store. It just feeds in plain strings and checks the answer.
from fix_invalid_friendly_url_format import is_valid_slug, slugify
def test_full_url_is_invalid():
assert is_valid_slug("abc.com") is False
def test_plain_slug_is_valid():
assert is_valid_slug("my-product-title") is True
def test_accented_slug_valid_when_allowed():
assert is_valid_slug("cafe-noir", allow_accented=True) is True
def test_accented_value_invalid_when_not_allowed():
# a plain-mode check should not need real accented characters to fail,
# underscores and hyphens alone must still pass
assert is_valid_slug("cafe_noir-2") is True
def test_space_is_invalid_even_in_accented_mode():
assert is_valid_slug("cafe noir", allow_accented=True) is False
def test_empty_string_is_invalid():
assert is_valid_slug("") is False
def test_slash_is_invalid():
assert is_valid_slug("path/to/thing") is False
def test_scheme_like_value_is_invalid():
assert is_valid_slug("https://example.com") is False
def test_slugify_strips_accents_and_punctuation():
assert slugify("Café Noir!") == "cafe-noir"
def test_slugify_falls_back_when_empty():
assert slugify("") == "untitled"
import { test } from "node:test";
import assert from "node:assert/strict";
import { isValidSlug, slugify } from "./fix-invalid-friendly-url-format.js";
test("full url is invalid", () => {
assert.equal(isValidSlug("abc.com"), false);
});
test("plain slug is valid", () => {
assert.equal(isValidSlug("my-product-title"), true);
});
test("accented slug valid when allowed", () => {
assert.equal(isValidSlug("cafe-noir", true), true);
});
test("underscore and hyphen alone still pass in plain mode", () => {
assert.equal(isValidSlug("cafe_noir-2"), true);
});
test("space is invalid even in accented mode", () => {
assert.equal(isValidSlug("cafe noir", true), false);
});
test("empty string is invalid", () => {
assert.equal(isValidSlug(""), false);
});
test("slash is invalid", () => {
assert.equal(isValidSlug("path/to/thing"), false);
});
test("scheme-like value is invalid", () => {
assert.equal(isValidSlug("https://example.com"), false);
});
test("slugify strips accents and punctuation", () => {
assert.equal(slugify("Café Noir!"), "cafe-noir");
});
test("slugify falls back when empty", () => {
assert.equal(slugify(""), "untitled");
});
Case studies
The integration that posted the source system's URL
A PIM-to-PrestaShop sync wrote products through the webservice and set link_rewrite from the source system's own canonical URL field, since that column happened to look like a slug at a glance in a spreadsheet preview. The API accepted every one of those values on creation without complaint.
The break only showed up when a merchandiser opened one of those products in the back office to tweak a price, and the save button failed with a link_rewrite validation error the form had never let them see before. Running the detector against the catalog found dozens of products with a full URL sitting in their slug field, all imported the same week. Repairing them behind a dry run first, then for real, cleared the back office error and fixed the storefront 404s in one pass.
The language that never got slugified
A store selling in three languages had a translation workflow that filled in the product name for each language, but a bug in the connector left link_rewrite copied verbatim from the translated title instead of running it through a slugifier, so the secondary language ended up with spaces and accented punctuation baked into the URL field.
The primary language looked completely fine, so nobody noticed until an SEO audit tool flagged a batch of 404s that only appeared when the site language switcher was set to the secondary language. The script's per-language check caught exactly that language and left the already-valid primary language slugs untouched.
After this runs on a schedule, an invalid slug turns into a clear, logged repair naming the resource, the language, and the old and new value, instead of a silent 404 or a back-office save that fails for no obvious reason. Repairs only ever happen behind DRY_RUN=false, and the store's own 301 redirect preference keeps the old URL from going dead once the new one takes over.
FAQ
Why did the webservice accept a link_rewrite value that is not a valid slug?
PrestaShop's webservice layer validates most product fields strictly, but it does not consistently run link_rewrite through Validate::isLinkRewrite() on every write path. Reported bugs show the API accepting values such as a full URL on product creation, because validation differs between POST and PUT and the back-office ObjectModel::validateFields() flow, so the bad value saves cleanly and only surfaces later when the row is re-saved through the form.
What does a valid link_rewrite look like?
A valid link_rewrite matches PrestaShop's own regex, letters, digits, underscores, and hyphens only, or that same set plus accented letters when PS_ALLOW_ACCENTED_CHARS_URL is turned on. It never contains dots, slashes, colons, whitespace, or control characters. Tools::str2url() is meant to turn any free-text title into a value in that shape, but it is not guaranteed to run on every write path.
Is it safe to auto-fix an invalid link_rewrite?
Rewriting a slug is safe for the store'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 reminds you to turn on PrestaShop's own 301 redirect preference for changed product URLs before you flip DRY_RUN to false.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Wrong link_rewrite format but still can be POST via webserver. Issue #13151. github.com/PrestaShop/PrestaShop/issues/13151
- PrestaShop GitHub: Webservice link_rewrite validation error on Product creation due to Tools::str2url in PS8. Issue #38161. github.com/PrestaShop/PrestaShop/issues/38161
- PrestaShop GitHub: Unable to set link rewrite to brands and suppliers. Issue #27716. github.com/PrestaShop/PrestaShop/issues/27716
On the solution:
- PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products/
- PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az/
- PrestaShop Developer Documentation: The PrestaShop Webservice API. devdocs.prestashop-project.org/9/webservice/
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 product page or a confusing back-office save error, 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