Reconciler Multistore
Orphaned combinations remain linked after a product is removed from a shop
You uncheck a shop in a product's Shops association panel, or remove it through Product V2, and the product itself looks clean everywhere in that shop. But its combinations do not always follow. A stale row can keep a combination linked to the shop you just removed, still able to resolve, price, or stock-check against a shop context the parent product no longer belongs to. Here is why PrestaShop's core does not cascade that cleanup, and a small script that finds and reports every orphaned tuple.
A product's shop association lives in ps_product_shop, but each combination's per-shop presence lives in a separate table, ps_product_attribute_shop. Removing a shop from the product only cleans up the product_shop row. Core does not cascade that removal to the combination's product_attribute_shop rows, a documented bug (PrestaShop/PrestaShop#30751). Run a script that lists active shops with GET /api/shops, reads the product's own associations.shops, lists its combinations with GET /api/combinations?filter[id_product]=X, then checks each combination against every shop it should no longer belong to with GET /api/combinations/{id_product_attribute}?id_shop={id_shop}. Any combination that still resolves for a shop the product itself has left, or for a shop that is no longer active, is orphaned. There is no single-row delete route for this on the webservice, so the script reports the exact tuples for a human or database admin to review. Full code, tests, and a dry run guard are below.
The problem in plain words
In multistore mode, PrestaShop does not store "this product belongs to this shop" in one place and leave it there. The product's own association lives in ps_product_shop. Each of its combinations gets its own, separate presence table, ps_product_attribute_shop, keyed by id_product_attribute and id_shop.
When a merchant unchecks a shop in the product's Shops association panel, or removes the product from a shop through Product V2, PrestaShop's deletion logic cleans up the product_shop row for that shop. It does not walk down to the product's combinations and clean up their matching product_attribute_shop rows for that same shop. The result: the product looks correctly unassigned from the shop, but its combinations still carry a live association row for a shop the parent product no longer belongs to, and that combination can still resolve, price, or stock-check against a shop context it should never see again.
Why it happens
PrestaShop tracks a product's shop membership and a combination's shop membership in two separate tables, and the removal path only reliably clears one of them. A few recurring ways stores end up with orphaned rows:
- A merchant unchecks a shop in the product's Shops association panel in the classic Back Office edit screen, and the combinations keep their
product_attribute_shoprows for that shop. - The same removal happens through Product V2, PrestaShop's newer product page architecture, which has the identical gap. This is a documented core bug tracked as PrestaShop/PrestaShop#30751, "Combinations are not deleted when removing product from shop."
- A shop itself is deactivated or deleted at the store level, but combinations across many products that were assigned to it keep resolving as if that shop context still existed.
- Bulk multistore reorganizations, consolidating shops, reassigning products between shops during a catalog restructure, multiply the same gap across the whole catalog at once.
This is a known core defect, not a one-off misconfiguration. PrestaShop's own issue tracker documents the missing cascade, and related pull requests attempted partial fixes to combination generation and deletion under multistore. See the citations at the end for the exact reports and patches.
The product's own associations.shops list is not the full picture in multistore. A combination can keep a live association to a shop that the parent product itself no longer lists, because the two are tracked in different tables with no enforced cascade between them. The only reliable way to find an orphan is to check each combination directly, in the context of every shop the product should no longer be attached to, rather than trusting that the product's own shop list tells the whole story.
The fix, as a flow
We never assume a combination followed its product out of a shop. The script pulls the authoritative list of active shops, pulls the product's own shop associations, pulls every combination for the product, then checks each combination against every shop that is not in the product's own association set or not active at all. Anything that still resolves is flagged, not deleted, because the webservice has no route to remove a single combination-shop row.
Build it step by step
Get a webservice key with the right permissions
In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to shops, products, combinations, and stock_availables. 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 PRODUCT_IDS="12,34,56"
export DRY_RUN="true" # start safe, keeps this a report-only run
// 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 PRODUCT_IDS="12,34,56"
export DRY_RUN="true" // start safe, keeps this a report-only run
List active shops and the product's own shop associations
Ask shops for the authoritative set of active id_shop values. Then ask products/{id_product} and read associations.shops, the list of shops the product itself currently claims to belong to. Any shop outside both of these for a given combination is a candidate orphan.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def active_shop_ids():
data = api_get("shops", {"display": "full"})
return {int(s["id"]) for s in (data.get("shops") or [])}
def product_shop_ids(id_product):
data = api_get(f"products/{id_product}", {"display": "full"})
shops = ((data.get("product") or {}).get("associations", {}) or {}).get("shops") or []
return {int(s["id"]) for s in shops}
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function activeShopIds() {
const data = await apiGet("shops", { display: "full" });
return new Set((data.shops || []).map((s) => Number(s.id)));
}
async function productShopIds(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
const shops = data.product?.associations?.shops || [];
return new Set(shops.map((s) => Number(s.id)));
}
List the product's combinations, then check each one per shop
Ask combinations for every id_product_attribute belonging to the product. The combinations resource does not expose per-shop rows directly, so for every shop the product is not associated with, or that is no longer active, request that combination scoped with id_shop. A 200 response that still returns combination data flags a live row for a shop it should not have.
def combinations_for_product(id_product):
data = api_get("combinations", {"display": "full", "filter[id_product]": id_product, "limit": 0})
rows = data.get("combinations") or []
return [int(r["id"]) for r in rows]
def combination_resolves_for_shop(id_product_attribute, id_shop):
try:
data = api_get(f"combinations/{id_product_attribute}", {"display": "full", "id_shop": id_shop})
except requests.HTTPError:
return False
return bool(data.get("combination"))
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct, limit: 0 });
const rows = data.combinations || [];
return rows.map((r) => Number(r.id));
}
async function combinationResolvesForShop(idProductAttribute, idShop) {
try {
const data = await apiGet(`combinations/${idProductAttribute}`, { display: "full", id_shop: idShop });
return Boolean(data.combination);
} catch (err) {
return false;
}
}
Decide, with one pure function
Keep the actual decision in its own function that takes plain sets and lists in and returns plain data out. A combination-shop row is orphaned when its shop is not one the product itself is associated with, or when that shop is not active at all. Two different reasons, so the result carries a reason key rather than a bare flag, which makes the report readable and the test cases easy to tell apart.
def find_orphaned_combination_shops(product_shop_ids, active_shop_ids, combination_shop_rows):
orphans = []
for row in combination_shop_rows:
id_shop = row["id_shop"]
if id_shop not in active_shop_ids:
orphans.append({**row, "reason": "shop_inactive"})
elif id_shop not in product_shop_ids:
orphans.append({**row, "reason": "shop_unassigned_from_product"})
return orphans
export function findOrphanedCombinationShops(productShopIds, activeShopIds, combinationShopRows) {
const orphans = [];
for (const row of combinationShopRows) {
const idShop = row.id_shop;
if (!activeShopIds.has(idShop)) {
orphans.push({ ...row, reason: "shop_inactive" });
} else if (!productShopIds.has(idShop)) {
orphans.push({ ...row, reason: "shop_unassigned_from_product" });
}
}
return orphans;
}
Cross-check stock_availables, then report
An orphaned combination-shop link often carries a live stock_available row too. Cross-reference GET /api/stock_availables?filter[id_product]={id}&filter[id_product_attribute]={id_pa}&filter[id_shop]={id_shop} to confirm it, then log the finding. There is no webservice route to delete a single product_attribute_shop row, so the script never writes. It prints every (id_product, id_product_attribute, orphaned id_shop) tuple for a human or database admin to review before any DELETE FROM ps_product_attribute_shop is run outside the webservice.
def has_stock_row(id_product, id_product_attribute, id_shop):
data = api_get("stock_availables", {
"display": "full",
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"filter[id_shop]": id_shop,
})
return bool(data.get("stock_availables"))
async function hasStockRow(idProduct, idProductAttribute, idShop) {
const data = await apiGet("stock_availables", {
display: "full",
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
"filter[id_shop]": idShop,
});
return Boolean(data.stock_availables);
}
Wire it together
The run loop pulls the product ids you point it at, checks every combination against every shop it should no longer belong to, and logs each orphaned tuple along with whether it still carries a live stock row. DRY_RUN defaults to true and this script never flips a write path on its own, since there is nothing safe for it to write. It always reports, so a database admin can review the exact rows before running a guarded SQL delete against ps_product_attribute_shop.
This script only reports. It never calls a delete route, because the combinations webservice resource can only remove a combination wholesale with DELETE /api/combinations/{id}, which would strip it from every shop, not just the stale one. The safe fix is a core patch that cascades the shop disassociation, or a DRY_RUN-guarded SQL statement against ps_product_attribute_shop scoped to id_product_attribute and the stale id_shop, run by a human after reviewing this report.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and never writes, because reviewing before any repair is the whole point when the only safe write is a scoped SQL statement outside the webservice.
"""Find PrestaShop combinations that remain linked to a shop after the parent
product was removed from that shop, in multistore mode.
A product's shop association lives in product_shop. A combination's per-shop
presence lives in a separate table, product_attribute_shop. Removing a shop
from a product (unchecking it in the Shops association panel, or through
Product V2) only cleans up product_shop. Core does not cascade that removal
to the combination's product_attribute_shop rows, a documented bug
(PrestaShop/PrestaShop#30751). This lists active shops, reads the product's
own shop associations, lists its combinations, and checks each combination
against every shop it should no longer belong to. There is no webservice
route to delete a single product_attribute_shop row, so this script only
reports the orphaned (id_product, id_product_attribute, id_shop) tuples for
a human or database admin to review. DRY_RUN defaults to true and the
script never writes. 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("orphaned_combination_shops")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRODUCT_IDS = [int(p) for p in os.environ.get("PRODUCT_IDS", "").split(",") if p.strip()]
def api_get(path, params):
params = dict(params)
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def active_shop_ids():
data = api_get("shops", {"display": "full"})
return {int(s["id"]) for s in (data.get("shops") or [])}
def product_shop_ids(id_product):
data = api_get(f"products/{id_product}", {"display": "full"})
shops = ((data.get("product") or {}).get("associations", {}) or {}).get("shops") or []
return {int(s["id"]) for s in shops}
def combinations_for_product(id_product):
data = api_get("combinations", {"display": "full", "filter[id_product]": id_product, "limit": 0})
rows = data.get("combinations") or []
return [int(r["id"]) for r in rows]
def combination_resolves_for_shop(id_product_attribute, id_shop):
try:
data = api_get(f"combinations/{id_product_attribute}", {"display": "full", "id_shop": id_shop})
except requests.HTTPError:
return False
return bool(data.get("combination"))
def has_stock_row(id_product, id_product_attribute, id_shop):
data = api_get("stock_availables", {
"display": "full",
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"filter[id_shop]": id_shop,
})
return bool(data.get("stock_availables"))
def find_orphaned_combination_shops(product_shop_ids, active_shop_ids, combination_shop_rows):
orphans = []
for row in combination_shop_rows:
id_shop = row["id_shop"]
if id_shop not in active_shop_ids:
orphans.append({**row, "reason": "shop_inactive"})
elif id_shop not in product_shop_ids:
orphans.append({**row, "reason": "shop_unassigned_from_product"})
return orphans
def candidate_shop_ids(prod_shop_ids, all_active_shop_ids, all_known_shop_ids):
# Any shop the product is not associated with, plus any shop that used to
# exist but is no longer active, are the shops worth checking per combination.
return all_known_shop_ids - prod_shop_ids
def run():
all_active = active_shop_ids()
reported = 0
for id_product in PRODUCT_IDS:
prod_shops = product_shop_ids(id_product)
combo_ids = combinations_for_product(id_product)
# Shops worth probing: every active shop the product itself is not
# associated with. Shops the product also is not on but that became
# inactive would already be excluded from all_active, so we still
# need at least one known shop set to probe against; active shops
# cover the documented bug (#30751) directly.
shops_to_check = all_active - prod_shops
combination_shop_rows = []
for id_product_attribute in combo_ids:
for id_shop in shops_to_check:
if combination_resolves_for_shop(id_product_attribute, id_shop):
combination_shop_rows.append({
"id_product_attribute": id_product_attribute,
"id_shop": id_shop,
})
orphans = find_orphaned_combination_shops(prod_shops, all_active, combination_shop_rows)
for orphan in orphans:
has_stock = has_stock_row(id_product, orphan["id_product_attribute"], orphan["id_shop"])
log.warning(
"Product %s combination %s orphaned for shop %s (%s), stock row present: %s",
id_product, orphan["id_product_attribute"], orphan["id_shop"],
orphan["reason"], has_stock,
)
reported += 1
log.info("Done. %d orphaned combination-shop tuple(s) found. Report only, nothing was written.", reported)
if __name__ == "__main__":
run()
/**
* Find PrestaShop combinations that remain linked to a shop after the parent
* product was removed from that shop, in multistore mode.
*
* A product's shop association lives in product_shop. A combination's per-shop
* presence lives in a separate table, product_attribute_shop. Removing a shop
* from a product only cleans up product_shop. Core does not cascade that
* removal to the combination's product_attribute_shop rows, a documented bug
* (PrestaShop/PrestaShop#30751). This lists active shops, reads the product's
* own shop associations, lists its combinations, and checks each combination
* against every shop it should no longer belong to. There is no webservice
* route to delete a single product_attribute_shop row, so this script only
* reports the orphaned tuples. DRY_RUN defaults to true and the script never
* writes. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/orphaned-combinations-after-shop-removal/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://example.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "dummy_key";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRODUCT_IDS = (process.env.PRODUCT_IDS || "").split(",").map((p) => p.trim()).filter(Boolean).map(Number);
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params) {
const url = new URL(`${BASE_URL}/api/${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function activeShopIds() {
const data = await apiGet("shops", { display: "full" });
return new Set((data.shops || []).map((s) => Number(s.id)));
}
async function productShopIds(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
const shops = data.product?.associations?.shops || [];
return new Set(shops.map((s) => Number(s.id)));
}
async function combinationsForProduct(idProduct) {
const data = await apiGet("combinations", { display: "full", "filter[id_product]": idProduct, limit: 0 });
const rows = data.combinations || [];
return rows.map((r) => Number(r.id));
}
async function combinationResolvesForShop(idProductAttribute, idShop) {
try {
const data = await apiGet(`combinations/${idProductAttribute}`, { display: "full", id_shop: idShop });
return Boolean(data.combination);
} catch (err) {
return false;
}
}
async function hasStockRow(idProduct, idProductAttribute, idShop) {
const data = await apiGet("stock_availables", {
display: "full",
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
"filter[id_shop]": idShop,
});
return Boolean(data.stock_availables);
}
export function findOrphanedCombinationShops(productShopIds, activeShopIds, combinationShopRows) {
const orphans = [];
for (const row of combinationShopRows) {
const idShop = row.id_shop;
if (!activeShopIds.has(idShop)) {
orphans.push({ ...row, reason: "shop_inactive" });
} else if (!productShopIds.has(idShop)) {
orphans.push({ ...row, reason: "shop_unassigned_from_product" });
}
}
return orphans;
}
export async function run() {
const allActive = await activeShopIds();
let reported = 0;
for (const idProduct of PRODUCT_IDS) {
const prodShops = await productShopIds(idProduct);
const comboIds = await combinationsForProduct(idProduct);
const shopsToCheck = [...allActive].filter((id) => !prodShops.has(id));
const combinationShopRows = [];
for (const idProductAttribute of comboIds) {
for (const idShop of shopsToCheck) {
if (await combinationResolvesForShop(idProductAttribute, idShop)) {
combinationShopRows.push({ id_product_attribute: idProductAttribute, id_shop: idShop });
}
}
}
const orphans = findOrphanedCombinationShops(prodShops, allActive, combinationShopRows);
for (const orphan of orphans) {
const hasStock = await hasStockRow(idProduct, orphan.id_product_attribute, orphan.id_shop);
console.warn(
`Product ${idProduct} combination ${orphan.id_product_attribute} orphaned for shop ${orphan.id_shop} (${orphan.reason}), stock row present: ${hasStock}`
);
reported++;
}
}
console.log(`Done. ${reported} orphaned combination-shop tuple(s) found. Report only, nothing was written.`);
}
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 tuples get reported to a human. Because find_orphaned_combination_shops is pure, the test needs no PrestaShop instance and no network. It just feeds in plain sets and lists and checks the answer, including the reason each orphan is tagged with.
from orphaned_combination_shops import find_orphaned_combination_shops
def row(**over):
base = {"id_product_attribute": 10, "id_shop": 1}
base.update(over)
return base
def test_no_orphans_when_every_row_matches_product_and_active_shops():
product_shop_ids = {1, 2}
active_shop_ids = {1, 2}
rows = [row(id_shop=1), row(id_shop=2, id_product_attribute=11)]
assert find_orphaned_combination_shops(product_shop_ids, active_shop_ids, rows) == []
def test_shop_unassigned_from_product_is_orphaned():
product_shop_ids = {1}
active_shop_ids = {1, 2}
rows = [row(id_shop=2)]
result = find_orphaned_combination_shops(product_shop_ids, active_shop_ids, rows)
assert result == [{"id_product_attribute": 10, "id_shop": 2, "reason": "shop_unassigned_from_product"}]
def test_inactive_shop_is_orphaned_even_if_product_still_lists_it():
product_shop_ids = {1, 3}
active_shop_ids = {1}
rows = [row(id_shop=3)]
result = find_orphaned_combination_shops(product_shop_ids, active_shop_ids, rows)
assert result == [{"id_product_attribute": 10, "id_shop": 3, "reason": "shop_inactive"}]
def test_inactive_shop_reason_wins_over_unassigned_reason():
# A shop that is both unassigned from the product and globally inactive
# is reported once, tagged shop_inactive, since that is the stronger reason.
product_shop_ids = set()
active_shop_ids = set()
rows = [row(id_shop=9)]
result = find_orphaned_combination_shops(product_shop_ids, active_shop_ids, rows)
assert result == [{"id_product_attribute": 10, "id_shop": 9, "reason": "shop_inactive"}]
def test_empty_rows_returns_empty_list():
assert find_orphaned_combination_shops({1}, {1}, []) == []
def test_multiple_combinations_each_orphaned_independently():
product_shop_ids = {1}
active_shop_ids = {1, 2}
rows = [row(id_product_attribute=10, id_shop=2), row(id_product_attribute=11, id_shop=2)]
result = find_orphaned_combination_shops(product_shop_ids, active_shop_ids, rows)
assert result == [
{"id_product_attribute": 10, "id_shop": 2, "reason": "shop_unassigned_from_product"},
{"id_product_attribute": 11, "id_shop": 2, "reason": "shop_unassigned_from_product"},
]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedCombinationShops } from "./orphaned-combination-shops.js";
const row = (over = {}) => ({ id_product_attribute: 10, id_shop: 1, ...over });
test("no orphans when every row matches product and active shops", () => {
const productShopIds = new Set([1, 2]);
const activeShopIds = new Set([1, 2]);
const rows = [row({ id_shop: 1 }), row({ id_shop: 2, id_product_attribute: 11 })];
assert.deepEqual(findOrphanedCombinationShops(productShopIds, activeShopIds, rows), []);
});
test("shop unassigned from product is orphaned", () => {
const productShopIds = new Set([1]);
const activeShopIds = new Set([1, 2]);
const rows = [row({ id_shop: 2 })];
const result = findOrphanedCombinationShops(productShopIds, activeShopIds, rows);
assert.deepEqual(result, [{ id_product_attribute: 10, id_shop: 2, reason: "shop_unassigned_from_product" }]);
});
test("inactive shop is orphaned even if product still lists it", () => {
const productShopIds = new Set([1, 3]);
const activeShopIds = new Set([1]);
const rows = [row({ id_shop: 3 })];
const result = findOrphanedCombinationShops(productShopIds, activeShopIds, rows);
assert.deepEqual(result, [{ id_product_attribute: 10, id_shop: 3, reason: "shop_inactive" }]);
});
test("inactive shop reason wins over unassigned reason", () => {
const productShopIds = new Set();
const activeShopIds = new Set();
const rows = [row({ id_shop: 9 })];
const result = findOrphanedCombinationShops(productShopIds, activeShopIds, rows);
assert.deepEqual(result, [{ id_product_attribute: 10, id_shop: 9, reason: "shop_inactive" }]);
});
test("empty rows returns empty list", () => {
assert.deepEqual(findOrphanedCombinationShops(new Set([1]), new Set([1]), []), []);
});
test("multiple combinations each orphaned independently", () => {
const productShopIds = new Set([1]);
const activeShopIds = new Set([1, 2]);
const rows = [row({ id_product_attribute: 10, id_shop: 2 }), row({ id_product_attribute: 11, id_shop: 2 })];
const result = findOrphanedCombinationShops(productShopIds, activeShopIds, rows);
assert.deepEqual(result, [
{ id_product_attribute: 10, id_shop: 2, reason: "shop_unassigned_from_product" },
{ id_product_attribute: 11, id_shop: 2, reason: "shop_unassigned_from_product" },
]);
});
Case studies
The retailer consolidating regional shops
A retailer running separate PrestaShop shops per region merged two of them and removed a batch of seasonal products from the shop being retired. The products vanished from that shop's catalog view, so the team assumed the cleanup was complete.
Running the reconciler surfaced dozens of combinations still linked to the retired shop's id_shop, several with live stock rows. Nothing customer-facing broke yet, but the team caught the drift before a reused id_shop value on a future shop could have resolved stale variant data against it.
The catalog team using the new product page
A merchant using Product V2 unchecked a shop for a handful of products directly from the Shops association panel, expecting a clean removal since the product itself disappeared from that shop immediately.
The reconciler found that every one of those products still had combinations resolving against the removed shop's context through the webservice. It matched the exact gap described in PrestaShop/PrestaShop#30751, and the report gave the database admin the precise tuples to review before a scoped cleanup.
After this runs on a schedule, no combination silently keeps resolving against a shop its own product has left. Every orphaned (id_product, id_product_attribute, id_shop) tuple shows up in the report, tagged with why it is orphaned, before anyone runs a delete. The script itself never writes, which keeps the one place that can safely fix this, a reviewed SQL statement or a core patch, in human hands.
FAQ
Why does a combination stay linked to a shop after I remove the product from that shop?
In multistore, the product's own shop association lives in product_shop, but each combination's per-shop presence lives in a separate table, product_attribute_shop. Unchecking a shop in the product's Shops association panel only cleans up the product_shop row. Core deletion logic does not cascade that removal down to the combination's product_attribute_shop rows, so a stale row for the removed shop is left behind.
How do I detect an orphaned combination-shop link without direct database access?
Compare three things pulled from the Webservice API: the shop is active, the product's own associations.shops list, and the combination checked in that specific shop's context with id_shop on the combinations resource. If a combination still returns data for a shop that is not in the product's associations.shops, or for a shop that is no longer in the active shops list at all, that combination-shop pairing is orphaned.
Can I delete an orphaned combination-shop row through the Webservice API?
No, not the single row. The combinations resource only supports deleting a combination wholesale with DELETE /api/combinations/{id}, which removes it for every shop, not just the stale one. The correct repair is a core fix that cascades the shop disassociation, or a DRY_RUN-guarded SQL statement against product_attribute_shop run by a database admin. The script's job is to enumerate and report the orphaned tuples, not to delete them itself.
Related field notes
Citations
On the problem:
- Product V2: Combinations are not deleted when removing product from shop. github.com/PrestaShop/PrestaShop/issues/30751
- Combination generation and deletion multishop handling. github.com/PrestaShop/PrestaShop/pull/28395
- Combination multishop fix. github.com/PrestaShop/PrestaShop/pull/30683
On the solution:
- PrestaShop Developer Documentation: Combinations webservice resource. devdocs.prestashop-project.org/9/webservice/resources/combinations/
- PrestaShop Developer Documentation: Manage Multishop, webservice tutorials. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/manage-multishop/
- PrestaShop Developer Documentation: Products webservice resource, associations.shops. devdocs.prestashop-project.org/9/webservice/resources/products/
Stuck on a tricky one?
If you have a problem in PrestaShop multistore, combinations, 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 untangle your multistore catalog?
If this saved you a confusing afternoon chasing stale shop data, 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