Skip to content

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.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Using a laptop
Photo by Roberto Cortese on Unsplash
The short answer

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.

Webservice POST link_rewrite = "abc.com" isLinkRewrite skipped API path differs from admin form Row saves fine no error returned Rewrite rule cannot parse it Page 404s later, on visit
The webservice write path did not run the same slug check the back-office form runs, so an invalid value saved cleanly and only breaks the URL later.

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:

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.

The key insight

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.

List every resource products, categories, CMS, manufacturers Test link_rewrite per language is_valid_slug Does the value match the allowed charset? letters, digits, underscore, hyphen only Invalid value? yes no, move on Flag it Slugify the record's name, DRY_RUN guarded plus a note to enable the 301 redirect preference
Every value is checked against the exact regex PrestaShop itself enforces. Only values that fail get a candidate replacement, and only a confirmed run writes it.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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"
step2.js
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";
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

decide.py
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))
decide.js
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);
}
5

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.

slugify.py
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"
slugify.js
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";
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
fix_invalid_friendly_url_format.py
"""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()
fix-invalid-friendly-url-format.js
/**
 * 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.

test_invalid_slug.py
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"
invalid-friendly-url-format.test.js
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

Webservice import

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.

Multilingual catalog

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.

What good looks like

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:

  1. PrestaShop GitHub: Wrong link_rewrite format but still can be POST via webserver. Issue #13151. github.com/PrestaShop/PrestaShop/issues/13151
  2. PrestaShop GitHub: Webservice link_rewrite validation error on Product creation due to Tools::str2url in PS8. Issue #38161. github.com/PrestaShop/PrestaShop/issues/38161
  3. PrestaShop GitHub: Unable to set link rewrite to brands and suppliers. Issue #27716. github.com/PrestaShop/PrestaShop/issues/27716

On the solution:

  1. PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products/
  2. PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az/
  3. 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.

Contact me on LinkedIn

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

Back to all PrestaShop field notes