Reconciler
Product left without a valid default category after category deletion
Someone tidies up the catalog and deletes a category that is no longer needed. Everything looks fine in the back office category tree. Then a product that used to live under that category starts throwing errors on the storefront, or its default category shows up blank in the admin. The product still has other, perfectly valid categories. But its id_category_default field is still pointing at the category id you just deleted, and that id does not exist anymore. Here is why PrestaShop leaves that dangling reference behind and a small script that finds and repairs every product it happened to.
When a category is deleted in the PrestaShop back office, the deletion handler only reassigns a product's categories when the product would otherwise be left with zero categories at all. It does not check whether the deleted category happened to be that product's default category while the product still has other valid categories left. So the product row keeps its old id_category_default value, now a dangling reference to a category id that no longer exists in ps_category. This is a confirmed core bug (PrestaShop/PrestaShop issue #30219, and related issues #28016 and #9811). Run a Python or Node.js script that builds the set of valid category ids, compares every product's id_category_default against it, and for each affected product picks a replacement default from the product's own remaining categories, falling back to the shop's root category. Start in dry run, then apply. Full code, tests, and citations are below.
The problem in plain words
Every PrestaShop product carries an id_category_default field. It is the one category PrestaShop uses to build the product's canonical link and its breadcrumb on the storefront, even when the product is also filed under several other categories.
Deleting a category is a normal, everyday piece of catalog housekeeping. A season ends, a supplier line gets discontinued, someone reorganizes the tree. PrestaShop's DeleteCategoryHandler is careful about one thing: it will not leave a product with no categories at all, so if deleting a category would empty out a product's category list entirely, it reassigns that product somewhere else. What it does not check is whether the category being deleted was that product's default one. If the product still has other categories left over, the handler considers its job done and moves on, but the product row still points its id_category_default at the category id that just stopped existing.
Why it happens
This is a documented core bug in how category deletion interacts with the product's default category field, not a mistake made by whoever clicked delete. A few things make it easy to run into:
- The deletion logic is written to guarantee a product always keeps at least one category. It treats that as the only invariant worth protecting, so a product with three categories that loses one, even its default, passes the check without further action.
id_category_defaultlives on the product row itself, not as a foreign key PrestaShop enforces at the database level. Nothing stops it from continuing to hold a category id thatps_categoryno longer contains.- The back office product edit screen can show the default category field as blank or missing once the pointer is dangling, which is often the first visible symptom, well before anyone notices the storefront is affected.
- On the front office, the product page resolves its breadcrumb and canonical link from that same default category. When the lookup fails, the code that expects a category array gets a boolean false instead, and PHP throws a fatal error trying to access an array offset on it, which is exactly the failure mode confirmed in PrestaShop/PrestaShop issue #30219 and the related issues #28016 and #9811.
This has been reported and reproduced multiple times against current PrestaShop core versions. See the citations at the end for the exact issue threads and docs.
You cannot fix this by watching for it as categories get deleted, because the core does not surface an event for "this deletion left a dangling default." The safe pattern is to periodically reconcile the whole catalog: build the set of category ids that are actually still valid, then check every product's id_category_default against that set. Anything not in the set is broken, whether or not you know when it happened.
The fix, as a flow
We do not touch category deletion itself. We add a job that reads every valid category id, reads every product's default category and its full category list, and runs a pure decision function that picks a safe replacement default from the product's own remaining categories. A corrective write is only sent when explicitly allowed.
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 access to categories and products. 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 FALLBACK_ROOT_CATEGORY_ID="2" # shop root/home category id
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 FALLBACK_ROOT_CATEGORY_ID="2" // shop root/home category id
export DRY_RUN="true" // start safe, change to false to write
Talk to the webservice with HTTP Basic auth
Every call uses HTTP Basic auth where the webservice key is the username and the password is blank, and every request asks for JSON with output_format=JSON. A small helper handles both the GET reads and the PUT writes we need later.
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 api_put(path, resource_key, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"}, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
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 apiPut(path, resourceKey, 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({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
Build the set of valid category ids, then read every product
Call GET /api/categories?display=full&output_format=JSON, paginating with limit=, and collect every id into a set. That set is your ground truth for what still exists in ps_category. Then call GET /api/products?display=full&output_format=JSON, also paginated, and read each product's id, id_category_default, and its full category list under associations.categories.category[].id.
def all_category_ids(page_size=100):
ids, offset = set(), 0
while True:
data = api_get("categories", params={"display": "full", "limit": f"{offset},{page_size}"})
rows = data.get("categories") or []
if not rows:
return ids
ids.update(int(row["id"]) for row in rows)
offset += page_size
def all_products(page_size=100):
offset = 0
while True:
data = api_get("products", params={"display": "full", "limit": f"{offset},{page_size}"})
rows = data.get("products") or []
if not rows:
return
for row in rows:
yield row
offset += page_size
def associated_category_ids(product):
categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
return [int(row["id"]) for row in categories]
async function allCategoryIds(pageSize = 100) {
const ids = new Set();
let offset = 0;
while (true) {
const data = await apiGet("categories", { display: "full", limit: `${offset},${pageSize}` });
const rows = data.categories || [];
if (!rows.length) return ids;
for (const row of rows) ids.add(Number(row.id));
offset += pageSize;
}
}
async function* allProducts(pageSize = 100) {
let offset = 0;
while (true) {
const data = await apiGet("products", { display: "full", limit: `${offset},${pageSize}` });
const rows = data.products || [];
if (!rows.length) return;
for (const row of rows) yield row;
offset += pageSize;
}
}
function associatedCategoryIds(product) {
const categories = product.associations?.categories?.category || [];
return categories.map((row) => Number(row.id));
}
Decide, with one pure function
Keep the decision in its own function that takes only plain values, no I/O. It returns nothing to do when the current default is still valid. Otherwise it looks for a replacement among the product's own remaining valid categories, preferring the highest id as a simple deepest-category heuristic, and falls back to the shop's root category only when the product has no valid categories left at all.
def choose_valid_default_category(product_id, current_default_id, associated_category_ids,
valid_category_ids, fallback_root_id=2):
if current_default_id in valid_category_ids:
return {"id_product": product_id, "action": "none", "new_default": current_default_id}
candidates = [
cid for cid in associated_category_ids
if cid in valid_category_ids and cid != current_default_id
]
if candidates:
new_default = max(candidates)
else:
new_default = fallback_root_id if fallback_root_id in valid_category_ids else None
return {
"id_product": product_id,
"action": "reassign" if new_default else "flag_manual",
"old_default": current_default_id,
"new_default": new_default,
}
export function chooseValidDefaultCategory(productId, currentDefaultId, associatedCategoryIds,
validCategoryIds, fallbackRootId = 2) {
if (validCategoryIds.has(currentDefaultId)) {
return { id_product: productId, action: "none", new_default: currentDefaultId };
}
const candidates = associatedCategoryIds.filter(
(cid) => validCategoryIds.has(cid) && cid !== currentDefaultId
);
const newDefault = candidates.length
? Math.max(...candidates)
: (validCategoryIds.has(fallbackRootId) ? fallbackRootId : null);
return {
id_product: productId,
action: newDefault ? "reassign" : "flag_manual",
old_default: currentDefaultId,
new_default: newDefault,
};
}
Apply the repair with a full PUT
PrestaShop's webservice requires the complete resource body on a write, not a partial patch. So to repair a flagged product, fetch its current full body with GET /api/products/{id}?output_format=JSON, set id_category_default to the chosen valid id, make sure that id is present in associations.categories, and send the whole thing back with PUT /api/products/{id}?output_format=JSON.
def repair_product_default_category(product_id, new_default_id):
data = api_get(f"products/{product_id}")
product = data["product"]
product["id_category_default"] = new_default_id
categories = product.setdefault("associations", {}).setdefault("categories", {})
rows = categories.setdefault("category", [])
if not any(int(row["id"]) == new_default_id for row in rows):
rows.append({"id": new_default_id})
return api_put(f"products/{product_id}", "product", product)
async function repairProductDefaultCategory(productId, newDefaultId) {
const data = await apiGet(`products/${productId}`);
const product = data.product;
product.id_category_default = newDefaultId;
product.associations = product.associations || {};
product.associations.categories = product.associations.categories || {};
const rows = (product.associations.categories.category = product.associations.categories.category || []);
if (!rows.some((row) => Number(row.id) === newDefaultId)) {
rows.push({ id: newDefaultId });
}
return apiPut(`products/${productId}`, "product", product);
}
Wire it together with a dry run guard
The loop ties every piece together: build the valid category set, walk every product, run choose_valid_default_category, and log the proposed change. Under DRY_RUN=true it only logs {id_product, old id_category_default, new id_category_default} and never writes. When DRY_RUN=false, it fetches the product, applies the repair, and sends the full PUT. Products where no valid category exists at all, including the fallback, are flagged for manual review instead of guessed at.
Always start with DRY_RUN=true and read the proposed reassignments before writing anything. The webservice write is a full PUT of the product, so a mistake in the body can affect more than the default category field. Review the dry run log, confirm the picks look right, then flip DRY_RUN off.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because a product whose default category is already valid is always left untouched.
"""Repair PrestaShop products left with a dangling id_category_default after a
category deletion.
DeleteCategoryHandler only reassigns a product's categories when the deletion
would leave it with zero categories at all. It never checks whether the
deleted category was that product's default while the product still has
other valid categories, so id_category_default keeps pointing at a category
id that no longer exists in ps_category (PrestaShop/PrestaShop issue #30219,
and related issues #28016 and #9811).
This script builds the set of valid category ids, walks every product, and
runs a pure decision function that picks a replacement default from the
product's own remaining valid categories, falling back to the shop's root
category. It logs every proposed change. A corrective PUT that resends the
full product body is only sent when DRY_RUN=false.
Run on a schedule, or right after cleaning up the category tree. Safe to run
again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_default_category")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
FALLBACK_ROOT_CATEGORY_ID = int(os.environ.get("FALLBACK_ROOT_CATEGORY_ID", "2"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
def choose_valid_default_category(product_id, current_default_id, associated_category_ids,
valid_category_ids, fallback_root_id=2):
"""Pure decision function, no I/O.
product_id: int
current_default_id: int, the product's current id_category_default
associated_category_ids: list[int], the product's full category list
valid_category_ids: set[int], every category id that still exists
fallback_root_id: int, used only when the product has no valid
categories of its own left
Returns a dict describing what to do. action is "none" when the current
default is already valid, "reassign" when a safe replacement was found,
or "flag_manual" when no valid category exists to fall back to.
"""
if current_default_id in valid_category_ids:
return {"id_product": product_id, "action": "none", "new_default": current_default_id}
candidates = [
cid for cid in associated_category_ids
if cid in valid_category_ids and cid != current_default_id
]
if candidates:
new_default = max(candidates)
else:
new_default = fallback_root_id if fallback_root_id in valid_category_ids else None
return {
"id_product": product_id,
"action": "reassign" if new_default else "flag_manual",
"old_default": current_default_id,
"new_default": new_default,
}
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, resource_key, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"}, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def all_category_ids(page_size=100):
ids, offset = set(), 0
while True:
data = api_get("categories", params={"display": "full", "limit": f"{offset},{page_size}"})
rows = data.get("categories") or []
if not rows:
return ids
ids.update(int(row["id"]) for row in rows)
offset += page_size
def all_products(page_size=100):
offset = 0
while True:
data = api_get("products", params={"display": "full", "limit": f"{offset},{page_size}"})
rows = data.get("products") or []
if not rows:
return
for row in rows:
yield row
offset += page_size
def associated_category_ids(product):
categories = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
return [int(row["id"]) for row in categories]
def repair_product_default_category(product_id, new_default_id):
data = api_get(f"products/{product_id}")
product = data["product"]
product["id_category_default"] = new_default_id
categories = product.setdefault("associations", {}).setdefault("categories", {})
rows = categories.setdefault("category", [])
if not any(int(row["id"]) == new_default_id for row in rows):
rows.append({"id": new_default_id})
return api_put(f"products/{product_id}", "product", product)
def run():
valid_category_ids = all_category_ids()
reassigned = 0
flagged = 0
for product in all_products():
product_id = int(product["id"])
current_default_id = int(product.get("id_category_default") or 0)
decision = choose_valid_default_category(
product_id, current_default_id, associated_category_ids(product),
valid_category_ids, FALLBACK_ROOT_CATEGORY_ID,
)
if decision["action"] == "none":
continue
if decision["action"] == "flag_manual":
flagged += 1
log.warning("Product id=%s has no valid category to fall back to. Needs manual review.", product_id)
continue
log.info(
"Product id=%s old id_category_default=%s new id_category_default=%s. %s",
product_id, decision["old_default"], decision["new_default"],
"would reassign" if DRY_RUN else "reassigning",
)
if not DRY_RUN:
repair_product_default_category(product_id, decision["new_default"])
reassigned += 1
log.info("Done. %d product(s) %s, %d flagged for manual review.",
reassigned, "to reassign" if DRY_RUN else "reassigned", flagged)
if __name__ == "__main__":
run()
/**
* Repair PrestaShop products left with a dangling id_category_default after a
* category deletion.
*
* DeleteCategoryHandler only reassigns a product's categories when the deletion
* would leave it with zero categories at all. It never checks whether the
* deleted category was that product's default while the product still has
* other valid categories, so id_category_default keeps pointing at a category
* id that no longer exists in ps_category (PrestaShop/PrestaShop issue #30219,
* and related issues #28016 and #9811).
*
* This script builds the set of valid category ids, walks every product, and
* runs a pure decision function that picks a replacement default from the
* product's own remaining valid categories, falling back to the shop's root
* category. It logs every proposed change. A corrective PUT that resends the
* full product body is only sent when DRY_RUN=false.
*
* Guide: https://www.allanninal.dev/prestashop/product-missing-default-category-after-deletion/
*/
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 FALLBACK_ROOT_CATEGORY_ID = Number(process.env.FALLBACK_ROOT_CATEGORY_ID || 2);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* Pure decision function, no I/O.
*
* productId: number
* currentDefaultId: number, the product's current id_category_default
* associatedCategoryIds: number[], the product's full category list
* validCategoryIds: Set, every category id that still exists
* fallbackRootId: number, used only when the product has no valid
* categories of its own left
*
* Returns an object describing what to do. action is "none" when the current
* default is already valid, "reassign" when a safe replacement was found, or
* "flag_manual" when no valid category exists to fall back to.
*/
export function chooseValidDefaultCategory(productId, currentDefaultId, associatedCategoryIds,
validCategoryIds, fallbackRootId = 2) {
if (validCategoryIds.has(currentDefaultId)) {
return { id_product: productId, action: "none", new_default: currentDefaultId };
}
const candidates = associatedCategoryIds.filter(
(cid) => validCategoryIds.has(cid) && cid !== currentDefaultId
);
const newDefault = candidates.length
? Math.max(...candidates)
: (validCategoryIds.has(fallbackRootId) ? fallbackRootId : null);
return {
id_product: productId,
action: newDefault ? "reassign" : "flag_manual",
old_default: currentDefaultId,
new_default: newDefault,
};
}
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 apiPut(path, resourceKey, 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({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function allCategoryIds(pageSize = 100) {
const ids = new Set();
let offset = 0;
while (true) {
const data = await apiGet("categories", { display: "full", limit: `${offset},${pageSize}` });
const rows = data.categories || [];
if (!rows.length) return ids;
for (const row of rows) ids.add(Number(row.id));
offset += pageSize;
}
}
async function* allProducts(pageSize = 100) {
let offset = 0;
while (true) {
const data = await apiGet("products", { display: "full", limit: `${offset},${pageSize}` });
const rows = data.products || [];
if (!rows.length) return;
for (const row of rows) yield row;
offset += pageSize;
}
}
function associatedCategoryIds(product) {
const categories = product.associations?.categories?.category || [];
return categories.map((row) => Number(row.id));
}
async function repairProductDefaultCategory(productId, newDefaultId) {
const data = await apiGet(`products/${productId}`);
const product = data.product;
product.id_category_default = newDefaultId;
product.associations = product.associations || {};
product.associations.categories = product.associations.categories || {};
const rows = (product.associations.categories.category = product.associations.categories.category || []);
if (!rows.some((row) => Number(row.id) === newDefaultId)) {
rows.push({ id: newDefaultId });
}
return apiPut(`products/${productId}`, "product", product);
}
export async function run() {
const validCategoryIds = await allCategoryIds();
let reassigned = 0;
let flagged = 0;
for await (const product of allProducts()) {
const productId = Number(product.id);
const currentDefaultId = Number(product.id_category_default || 0);
const decision = chooseValidDefaultCategory(
productId, currentDefaultId, associatedCategoryIds(product),
validCategoryIds, FALLBACK_ROOT_CATEGORY_ID,
);
if (decision.action === "none") continue;
if (decision.action === "flag_manual") {
flagged++;
console.warn(`Product id=${productId} has no valid category to fall back to. Needs manual review.`);
continue;
}
console.log(
`Product id=${productId} old id_category_default=${decision.old_default} new id_category_default=${decision.new_default}. ${DRY_RUN ? "would reassign" : "reassigning"}`
);
if (!DRY_RUN) await repairProductDefaultCategory(productId, decision.new_default);
reassigned++;
}
console.log(`Done. ${reassigned} product(s) ${DRY_RUN ? "to reassign" : "reassigned"}, ${flagged} flagged for manual review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which category id ends up as a product's default, and it is the only place the logic branches between doing nothing, reassigning, and flagging for manual review. Because we kept choose_valid_default_category pure, the tests need no network and no PrestaShop store. They just feed in plain values and check the answer.
from fix_default_category import choose_valid_default_category
def test_no_action_when_default_is_already_valid():
result = choose_valid_default_category(10, 5, [5, 6], {5, 6, 2})
assert result == {"id_product": 10, "action": "none", "new_default": 5}
def test_reassigns_to_deepest_remaining_valid_category():
result = choose_valid_default_category(11, 99, [3, 7], {2, 3, 7})
assert result["action"] == "reassign"
assert result["old_default"] == 99
assert result["new_default"] == 7
def test_falls_back_to_root_when_no_valid_categories_left():
result = choose_valid_default_category(12, 99, [], {2, 3, 7})
assert result == {"id_product": 12, "action": "reassign", "old_default": 99, "new_default": 2}
def test_flags_manual_when_even_fallback_root_is_missing():
result = choose_valid_default_category(13, 99, [], {3, 7}, fallback_root_id=2)
assert result == {"id_product": 13, "action": "flag_manual", "old_default": 99, "new_default": None}
def test_ignores_associated_categories_that_are_also_invalid():
result = choose_valid_default_category(14, 99, [98, 97], {2, 3}, fallback_root_id=2)
assert result["action"] == "reassign"
assert result["new_default"] == 2
def test_excludes_current_default_from_candidates_even_if_technically_valid():
# current_default_id already handled by the first branch when it is valid,
# but associated_category_ids should never resurrect the same broken id
result = choose_valid_default_category(15, 99, [99, 6], {6, 2})
assert result["new_default"] == 6
import { test } from "node:test";
import assert from "node:assert/strict";
import { chooseValidDefaultCategory } from "./fix-default-category.js";
test("no action when default is already valid", () => {
const result = chooseValidDefaultCategory(10, 5, [5, 6], new Set([5, 6, 2]));
assert.deepEqual(result, { id_product: 10, action: "none", new_default: 5 });
});
test("reassigns to deepest remaining valid category", () => {
const result = chooseValidDefaultCategory(11, 99, [3, 7], new Set([2, 3, 7]));
assert.equal(result.action, "reassign");
assert.equal(result.old_default, 99);
assert.equal(result.new_default, 7);
});
test("falls back to root when no valid categories left", () => {
const result = chooseValidDefaultCategory(12, 99, [], new Set([2, 3, 7]));
assert.deepEqual(result, { id_product: 12, action: "reassign", old_default: 99, new_default: 2 });
});
test("flags manual when even fallback root is missing", () => {
const result = chooseValidDefaultCategory(13, 99, [], new Set([3, 7]), 2);
assert.deepEqual(result, { id_product: 13, action: "flag_manual", old_default: 99, new_default: null });
});
test("ignores associated categories that are also invalid", () => {
const result = chooseValidDefaultCategory(14, 99, [98, 97], new Set([2, 3]), 2);
assert.equal(result.action, "reassign");
assert.equal(result.new_default, 2);
});
test("excludes current default from candidates even if technically valid", () => {
const result = chooseValidDefaultCategory(15, 99, [99, 6], new Set([6, 2]));
assert.equal(result.new_default, 6);
});
Case studies
The winter collection that broke summer products
A fashion retailer deleted last year's "Winter 2025" category once the season ended, expecting a routine cleanup. A batch of accessories had been filed under Winter 2025 as their default category, but also carried the general "Accessories" category as a secondary. Those products still had a category, so nothing looked broken in the admin category tree, but every one of their product pages started throwing a fatal error on the storefront.
Running the reconciliation script in dry run turned up the exact list in seconds: every affected product still had the deleted category id sitting in id_category_default. The team reviewed the proposed reassignments, which all correctly picked "Accessories" as the new default, then ran it for real and the storefront errors stopped immediately.
An entire supplier's category, and no fallback anywhere
A hardware store discontinued a supplier and deleted that supplier's entire category branch, assuming every product under it had already been reassigned. A handful of clearance products had that branch as their only category association at all, meaning the deletion handler's zero-category safeguard should have caught them, but a timing issue in the bulk deletion left a few behind with no valid category whatsoever.
The script's flag_manual path caught exactly those products, since neither their own category list nor the fallback root category ID resolved to anything valid at that moment. The team fixed the root category id in their config, reran the script, and the fallback picked up the rest cleanly.
After this runs, every product's id_category_default points at a category that actually still exists, whether that means one of its own remaining categories or the shop's root as a last resort. Product pages stop throwing fatal errors from a dangling default, the back office shows a real category again instead of a blank field, and the handful of products with no valid category left get flagged instead of silently guessed at.
FAQ
Why does my PrestaShop product lose its default category after I delete a category?
PrestaShop's DeleteCategoryHandler only reassigns a product's categories when deleting the category would leave the product with zero categories at all. It never checks whether the deleted category was that product's id_category_default while the product still has other valid categories. The product row keeps the old id_category_default value, which now points at a category id that no longer exists in ps_category.
What breaks on the front office when id_category_default is dangling?
The product page tries to resolve its default category to build the breadcrumb and canonical link. When that category id no longer exists, Category.php cannot load a row for it, and the lookup that expects an array returns false instead. The page then throws a fatal error, commonly reported as Trying to access array offset on value of type bool, so the product becomes unreachable on the storefront.
How do I find every product with a dangling default category through the API?
Build a set of every valid category id from GET /api/categories?display=full&output_format=JSON, then walk every product from GET /api/products?display=full&output_format=JSON and compare each product's id_category_default against that set. A product is affected when id_category_default is missing from the valid set, or when it is 0 or empty while the product still has entries under associations.categories. You can also confirm with a direct GET /api/categories/{id} and treat a 404 as proof.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Default category should always be enforced on a product, even after category is deleted, issue #30219. github.com/PrestaShop/PrestaShop/issues/30219
- PrestaShop GitHub: Delete the main category of a product, product should be reassigned another main category, issue #9811. github.com/PrestaShop/PrestaShop/issues/9811
- PrestaShop GitHub: Default category does not update properly, issue #28016. github.com/PrestaShop/PrestaShop/issues/28016
On the solution:
- PrestaShop Developer Documentation: Products webservice resource, including id_category_default. devdocs.prestashop-project.org/9/webservice/resources/products
- PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
- PrestaShop Developer Documentation: 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, categories, stock, orders, 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 rescue a broken product page?
If this saved you a fatal error on the storefront or a mystery blank category field, 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