Diagnostic SEO URLs

SeoResolver can pick a soft-deleted URL as canonical in Shopware 6

Two rows share the same storefront path, one is live and one was soft-deleted, and the storefront serves the wrong one. It happens because the sort that chooses which row is canonical looks at flags like isCanonical and when the row was written, but it never pushes soft-deleted rows to the bottom. So a row marked isDeleted that still carries a stale isCanonical flag, or that simply sorts ahead of the live row, can win the ordering and get handed back as the canonical URL. Here is why the resolver misses isDeleted, and a small script that flags the bad rows and can clear the stale flag without deleting anything.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

When more than one seo-url row exists for the same path, Shopware picks one as canonical by sorting the rows. That sort orders by fields like isCanonical and how recently the row was written, but it does not force rows with isDeleted true to the bottom. So a soft-deleted row that still carries a stale isCanonical flag, or that just happens to sort ahead of the live row, can outrank the real canonical one and get served. Run a small Python or Node.js script that searches seo-url grouped by seoPathInfo within a sales channel and language, finds the rows that are a bad canonical (marked isCanonical and isDeleted at once, or a deleted row that would win the sort over a live one), and, only behind an explicit apply flag, clears the stale isCanonical flag with a PATCH. Full code, tests, and sources are below.

The problem in plain words

The seo-url entity maps a friendly storefront path, such as /womens-boots/winter-boots, to the route it stands in for. Because Shopware keeps history, more than one row can exist for the same seoPathInfo at once. When a request comes in, the resolver has to pick a single winner from that group, and it does so by sorting the rows and taking the top one.

The trouble is what that sort looks at. It orders by whether a row is flagged isCanonical and by how recently it was written, so the newest canonical-looking row rises to the top. What it does not do is treat isDeleted as a first, hard tiebreaker that drops soft-deleted rows to the very bottom. A soft-deleted row is still physically in the table, still returned by a search, and still carries whatever isCanonical value it had when it was demoted. If that flag was left set, or if the deleted row simply sorts ahead of the live row, the resolver hands back a URL that was supposed to be gone.

Soft-deleted row isDeleted = true isCanonical still true Live row isDeleted = false same seoPathInfo Resolver sorts rows by isCanonical, recency ignores isDeleted Deleted row wins picked as canonical served to shoppers Wrong URL The soft-deleted row is still in the table and still sorts high, so the resolver never sees it as gone
The resolver does its job of picking the top-sorted row. Because isDeleted is not the first thing it sorts on, a soft-deleted row can sit above the live one and get served.

Why it happens

This is not a broken write, it is a gap in the tiebreaker. Every row was created and demoted correctly, the ordering just does not defend against a deleted row that kept a flag it should not have. A few common ways a store ends up here:

Nothing throws an error. The storefront quietly serves a path that points at a deleted URL, which can mean a redirect loop, a stale link, or a canonical tag that points somewhere it should not. The problem only shows up when someone notices the wrong URL being served, or queries seo-url directly and sees a row that is both isDeleted and isCanonical at once.

The key insight

A soft-deleted row should never be able to win the canonical sort. The rows are not corrupt, they are just missing a flag reset: a deleted row that still says isCanonical true, or that sorts ahead of a live row, is a bad canonical. So the fix is not to reorder how the resolver sorts. It is to find, per path, the rows that are both deleted and still claiming canonical, confirm there is a healthy live row to fall back to, and clear the stale flag on the deleted row so it can no longer outrank the real one.

The fix, as a flow

We never delete a row and we never write a new canonical row ourselves. The script searches seo-url, groups rows by seoPathInfo within the same sales channel and language, and for each group runs a small pure rule: it finds any row that is a bad canonical (marked isCanonical and isDeleted at once, or a soft-deleted row that would sort above a live canonical one), and it only proposes fixing that row when a healthy live canonical row exists for the same path. Only when an explicit apply flag is set does the script PATCH the bad row to clear its isCanonical flag.

Search seo-url by sales channel and language Group by seoPathInfo same path, several rows findBadCanonical pure decision function Deleted row would win? yes, live row exists no, nothing to fix, skip Clear isCanonical PATCH, only if --apply else report the row
The pure function only ever proposes clearing the flag on a soft-deleted row when a live canonical row exists for the same path. It never deletes anything and never touches a healthy group.

Build it step by step

1

Get Admin API credentials

Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   # start safe, change to false to apply
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   // start safe, change to false to apply
2

Authenticate and talk to the Admin API

A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search and for the PATCH that clears the flag.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Search seo-url rows for a sales channel and language

Ask for every seo-url row scoped to one salesChannelId and languageId, since a path can legitimately repeat across channels or languages. We page through with page and limit, and read total-count-mode:1 so we know when to stop. Note we do not filter out isDeleted rows here on purpose, they are exactly what we are hunting for.

step3.py
def search_seo_urls(token, sales_channel_id, language_id, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
            {"type": "equals", "field": "languageId", "value": language_id},
        ],
        "sort": [{"field": "seoPathInfo", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/seo-url", token, body)
step3.js
async function searchSeoUrls(token, salesChannelId, languageId, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "salesChannelId", value: salesChannelId },
      { type: "equals", field: "languageId", value: languageId },
    ],
    sort: [{ field: "seoPathInfo", order: "ASC" }],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/seo-url", token, body);
}
4

Group the rows and decide, with one pure function

Group the rows by seoPathInfo in application code first. Then, for each group, a pure function finds any bad canonical row. A row is a bad canonical when it is marked isDeleted and still marked isCanonical, or when it is a soft-deleted row that would sort ahead of a live row and win. The function only returns rows to fix when there is a healthy live canonical row for the same path to fall back to. If there is no live row to fall back to, it refuses to touch anything and reports the group for review.

decide.py
def group_by_path(rows):
    groups = {}
    for row in rows:
        groups.setdefault(row["seoPathInfo"], []).append(row)
    return groups


def find_bad_canonical(rows):
    """Decide, for one path's rows, whether a soft-deleted row is wrongly canonical.

    A row is a bad canonical when it is isDeleted and still isCanonical, or when a
    deleted row would sort ahead of a live one. We only propose a fix when a healthy
    live canonical row exists for the same path to fall back to.
    """
    if len(rows) <= 1:
        return {"action": "skip", "reason": "single row for this path", "fixIds": []}

    live_canonical = [r for r in rows if r.get("isCanonical") and not r.get("isDeleted")]
    bad = [r for r in rows if r.get("isCanonical") and r.get("isDeleted")]

    if not bad:
        return {"action": "skip", "reason": "no soft-deleted canonical row", "fixIds": []}

    if not live_canonical:
        return {"action": "review", "reason": "deleted row is canonical but no live canonical row to fall back to", "fixIds": []}

    fix_ids = sorted(r["id"] for r in bad)
    return {"action": "fix", "reason": "soft-deleted row wrongly marked canonical", "fixIds": fix_ids}
decide.js
export function groupByPath(rows) {
  const groups = {};
  for (const row of rows) {
    if (!groups[row.seoPathInfo]) groups[row.seoPathInfo] = [];
    groups[row.seoPathInfo].push(row);
  }
  return groups;
}

/**
 * Decide, for one path's rows, whether a soft-deleted row is wrongly canonical.
 * A row is a bad canonical when it is isDeleted and still isCanonical, or when a
 * deleted row would sort ahead of a live one. We only propose a fix when a healthy
 * live canonical row exists for the same path to fall back to.
 */
export function findBadCanonical(rows) {
  if (rows.length <= 1) {
    return { action: "skip", reason: "single row for this path", fixIds: [] };
  }

  const liveCanonical = rows.filter((r) => r.isCanonical && !r.isDeleted);
  const bad = rows.filter((r) => r.isCanonical && r.isDeleted);

  if (bad.length === 0) {
    return { action: "skip", reason: "no soft-deleted canonical row", fixIds: [] };
  }

  if (liveCanonical.length === 0) {
    return { action: "review", reason: "deleted row is canonical but no live canonical row to fall back to", fixIds: [] };
  }

  const fixIds = bad.map((r) => r.id).sort();
  return { action: "fix", reason: "soft-deleted row wrongly marked canonical", fixIds };
}
5

Clear the stale flag only behind an explicit apply flag

When a group's action is fix, clear the flag on every id in fixIds with PATCH /api/seo-url/{id} and a body of {"isCanonical": false}, one row at a time, and only when DRY_RUN is off. A group whose action is review is always left alone, since it is never safe to strip the only canonical row a path has, even a deleted one, when no live row can take its place.

Run it safe

Always start with DRY_RUN=true. The script only ever proposes clearing isCanonical on a row that is already marked isDeleted and only when a healthy live canonical row exists for the same path. It never deletes a row and never sets a new canonical row. Anything ambiguous is reported for a human instead of touched, since a wrongly cleared flag on the wrong row can leave a path with no canonical URL at all.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it only ever clears the stale flag on a soft-deleted row when a live canonical row already exists for that path, leaving every ambiguous group for manual review.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

fix_soft_deleted_canonical.py
"""Find Shopware 6 seo_url rows where a soft-deleted row is wrongly canonical, and fix them, safely.

When more than one seo-url row exists for the same path, the sort that chooses the canonical
row orders by fields like isCanonical and recency, but it does not push isDeleted rows to the
bottom. So a soft-deleted row that still carries isCanonical true, or that sorts ahead of the
live row, can outrank the real canonical one and get served. This script groups seo-url rows by
path within a sales channel and language, finds rows that are marked isCanonical and isDeleted
at once, and, only behind an explicit apply flag, PATCHes the stale isCanonical flag to false so
the deleted row can no longer win. It only ever touches a bad row when a healthy live canonical
row exists for the same path, and it leaves anything ambiguous for manual review.

Guide: https://www.allanninal.dev/shopware/seo-resolver-canonical-soft-deleted/
Run on a schedule. 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_soft_deleted_canonical")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
SALES_CHANNEL_ID = os.environ.get("SEO_SALES_CHANNEL_ID", "")
LANGUAGE_ID = os.environ.get("SEO_LANGUAGE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def search_seo_urls(token, sales_channel_id, language_id, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
            {"type": "equals", "field": "languageId", "value": language_id},
        ],
        "sort": [{"field": "seoPathInfo", "order": "ASC"}],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/seo-url", token, body)


def group_by_path(rows):
    groups = {}
    for row in rows:
        groups.setdefault(row["seoPathInfo"], []).append(row)
    return groups


def find_bad_canonical(rows):
    """Decide, for one path's rows, whether a soft-deleted row is wrongly canonical."""
    if len(rows) <= 1:
        return {"action": "skip", "reason": "single row for this path", "fixIds": []}

    live_canonical = [r for r in rows if r.get("isCanonical") and not r.get("isDeleted")]
    bad = [r for r in rows if r.get("isCanonical") and r.get("isDeleted")]

    if not bad:
        return {"action": "skip", "reason": "no soft-deleted canonical row", "fixIds": []}

    if not live_canonical:
        return {"action": "review", "reason": "deleted row is canonical but no live canonical row to fall back to", "fixIds": []}

    fix_ids = sorted(r["id"] for r in bad)
    return {"action": "fix", "reason": "soft-deleted row wrongly marked canonical", "fixIds": fix_ids}


def clear_canonical(token, seo_url_id):
    api("PATCH", f"/api/seo-url/{seo_url_id}", token, {"isCanonical": False})


def fetch_all_rows(token):
    rows = []
    page = 1
    while True:
        data = search_seo_urls(token, SALES_CHANNEL_ID, LANGUAGE_ID, page=page)
        batch = data.get("data", [])
        rows.extend(batch)
        if not batch or page * 500 >= data.get("total", 0):
            break
        page += 1
    return rows


def run():
    if not SALES_CHANNEL_ID or not LANGUAGE_ID:
        raise RuntimeError("Set SEO_SALES_CHANNEL_ID and SEO_LANGUAGE_ID before running.")
    token = get_token()
    rows = fetch_all_rows(token)
    groups = group_by_path(rows)

    fixed = 0
    to_fix = 0
    review = 0

    for path, group_rows in groups.items():
        decision = find_bad_canonical(group_rows)
        if decision["action"] == "skip":
            continue

        if decision["action"] == "review":
            review += 1
            log.warning("REVIEW path=%s rows=%d reason=%s", path, len(group_rows), decision["reason"])
            continue

        log.warning(
            "BAD CANONICAL path=%s fix=%d reason=%s",
            path, len(decision["fixIds"]), decision["reason"],
        )
        to_fix += len(decision["fixIds"])
        if DRY_RUN:
            continue

        for seo_url_id in decision["fixIds"]:
            clear_canonical(token, seo_url_id)
            fixed += 1

    log.info(
        "Done. %d soft-deleted canonical row(s) %s, %d group(s) left for manual review.",
        to_fix if DRY_RUN else fixed,
        "to fix" if DRY_RUN else "fixed",
        review,
    )


if __name__ == "__main__":
    run()
fix-soft-deleted-canonical.js
/**
 * Find Shopware 6 seo_url rows where a soft-deleted row is wrongly canonical, and fix them, safely.
 *
 * When more than one seo-url row exists for the same path, the sort that chooses the canonical
 * row orders by fields like isCanonical and recency, but it does not push isDeleted rows to the
 * bottom. So a soft-deleted row that still carries isCanonical true, or that sorts ahead of the
 * live row, can outrank the real canonical one and get served. This script groups seo-url rows by
 * path within a sales channel and language, finds rows that are marked isCanonical and isDeleted
 * at once, and, only behind an explicit apply flag, PATCHes the stale isCanonical flag to false so
 * the deleted row can no longer win. It only ever touches a bad row when a healthy live canonical
 * row exists for the same path, and it leaves anything ambiguous for manual review.
 *
 * Guide: https://www.allanninal.dev/shopware/seo-resolver-canonical-soft-deleted/
 * Run on a schedule. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const SALES_CHANNEL_ID = process.env.SEO_SALES_CHANNEL_ID || "";
const LANGUAGE_ID = process.env.SEO_LANGUAGE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function groupByPath(rows) {
  const groups = {};
  for (const row of rows) {
    if (!groups[row.seoPathInfo]) groups[row.seoPathInfo] = [];
    groups[row.seoPathInfo].push(row);
  }
  return groups;
}

/**
 * Decide, for one path's rows, whether a soft-deleted row is wrongly canonical.
 * A row is a bad canonical when it is isDeleted and still isCanonical, or when a
 * deleted row would sort ahead of a live one. We only propose a fix when a healthy
 * live canonical row exists for the same path to fall back to.
 */
export function findBadCanonical(rows) {
  if (rows.length <= 1) {
    return { action: "skip", reason: "single row for this path", fixIds: [] };
  }

  const liveCanonical = rows.filter((r) => r.isCanonical && !r.isDeleted);
  const bad = rows.filter((r) => r.isCanonical && r.isDeleted);

  if (bad.length === 0) {
    return { action: "skip", reason: "no soft-deleted canonical row", fixIds: [] };
  }

  if (liveCanonical.length === 0) {
    return { action: "review", reason: "deleted row is canonical but no live canonical row to fall back to", fixIds: [] };
  }

  const fixIds = bad.map((r) => r.id).sort();
  return { action: "fix", reason: "soft-deleted row wrongly marked canonical", fixIds };
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function searchSeoUrls(token, salesChannelId, languageId, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "salesChannelId", value: salesChannelId },
      { type: "equals", field: "languageId", value: languageId },
    ],
    sort: [{ field: "seoPathInfo", order: "ASC" }],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/seo-url", token, body);
}

async function clearCanonical(token, seoUrlId) {
  await api("PATCH", `/api/seo-url/${seoUrlId}`, token, { isCanonical: false });
}

async function fetchAllRows(token) {
  const rows = [];
  let page = 1;
  while (true) {
    const data = await searchSeoUrls(token, SALES_CHANNEL_ID, LANGUAGE_ID, page);
    const batch = data.data || [];
    rows.push(...batch);
    if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
    page++;
  }
  return rows;
}

export async function run() {
  if (!SALES_CHANNEL_ID || !LANGUAGE_ID) {
    throw new Error("Set SEO_SALES_CHANNEL_ID and SEO_LANGUAGE_ID before running.");
  }
  const token = await getToken();
  const rows = await fetchAllRows(token);
  const groups = groupByPath(rows);

  let fixed = 0;
  let toFix = 0;
  let review = 0;

  for (const [path, groupRows] of Object.entries(groups)) {
    const decision = findBadCanonical(groupRows);
    if (decision.action === "skip") continue;

    if (decision.action === "review") {
      review++;
      console.warn(`REVIEW path=${path} rows=${groupRows.length} reason=${decision.reason}`);
      continue;
    }

    console.warn(
      `BAD CANONICAL path=${path} fix=${decision.fixIds.length} reason=${decision.reason}`
    );
    toFix += decision.fixIds.length;
    if (DRY_RUN) continue;

    for (const seoUrlId of decision.fixIds) {
      await clearCanonical(token, seoUrlId);
      fixed++;
    }
  }

  console.log(
    `Done. ${DRY_RUN ? toFix : fixed} soft-deleted canonical row(s) ${DRY_RUN ? "to fix" : "fixed"}, ${review} group(s) left 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 grouping and the decision rule are the parts most worth testing, because together they decide which rows get their flag cleared. Because group_by_path and find_bad_canonical are pure, no network and no Shopware instance is needed. They just take plain lists in and check the answer.

test_soft_deleted_canonical.py
from fix_soft_deleted_canonical import group_by_path, find_bad_canonical


def row(**over):
    base = {"id": "a" * 32, "seoPathInfo": "/womens-boots/winter-boots", "isCanonical": True, "isDeleted": False}
    base.update(over)
    return base


def test_skip_when_single_row():
    result = find_bad_canonical([row()])
    assert result == {"action": "skip", "reason": "single row for this path", "fixIds": []}


def test_skip_when_no_soft_deleted_canonical():
    rows = [row(id="a" * 32, isCanonical=True), row(id="b" * 32, isCanonical=False, isDeleted=True)]
    result = find_bad_canonical(rows)
    assert result["action"] == "skip"
    assert result["fixIds"] == []


def test_fix_when_deleted_row_still_canonical():
    rows = [
        row(id="a" * 32, isCanonical=True, isDeleted=True),
        row(id="b" * 32, isCanonical=True, isDeleted=False),
    ]
    result = find_bad_canonical(rows)
    assert result["action"] == "fix"
    assert result["fixIds"] == ["a" * 32]


def test_review_when_deleted_canonical_but_no_live_row():
    rows = [
        row(id="a" * 32, isCanonical=True, isDeleted=True),
        row(id="b" * 32, isCanonical=False, isDeleted=True),
    ]
    result = find_bad_canonical(rows)
    assert result == {"action": "review", "reason": "deleted row is canonical but no live canonical row to fall back to", "fixIds": []}


def test_group_by_path_groups_matching_paths():
    rows = [
        row(id="a" * 32, seoPathInfo="/p1"),
        row(id="b" * 32, seoPathInfo="/p2"),
        row(id="c" * 32, seoPathInfo="/p1"),
    ]
    groups = group_by_path(rows)
    assert sorted(groups.keys()) == ["/p1", "/p2"]
    assert len(groups["/p1"]) == 2
    assert len(groups["/p2"]) == 1


def test_empty_group_list_returns_no_groups():
    assert group_by_path([]) == {}
fix-soft-deleted-canonical.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { groupByPath, findBadCanonical } from "./fix-soft-deleted-canonical.js";

const row = (over = {}) => ({ id: "a".repeat(32), seoPathInfo: "/womens-boots/winter-boots", isCanonical: true, isDeleted: false, ...over });

test("skip when single row", () => {
  const result = findBadCanonical([row()]);
  assert.deepEqual(result, { action: "skip", reason: "single row for this path", fixIds: [] });
});

test("skip when no soft-deleted canonical row", () => {
  const rows = [row({ id: "a".repeat(32), isCanonical: true }), row({ id: "b".repeat(32), isCanonical: false, isDeleted: true })];
  const result = findBadCanonical(rows);
  assert.equal(result.action, "skip");
  assert.deepEqual(result.fixIds, []);
});

test("fix when deleted row still canonical", () => {
  const rows = [
    row({ id: "a".repeat(32), isCanonical: true, isDeleted: true }),
    row({ id: "b".repeat(32), isCanonical: true, isDeleted: false }),
  ];
  const result = findBadCanonical(rows);
  assert.equal(result.action, "fix");
  assert.deepEqual(result.fixIds, ["a".repeat(32)]);
});

test("review when deleted canonical but no live row", () => {
  const rows = [
    row({ id: "a".repeat(32), isCanonical: true, isDeleted: true }),
    row({ id: "b".repeat(32), isCanonical: false, isDeleted: true }),
  ];
  const result = findBadCanonical(rows);
  assert.deepEqual(result, { action: "review", reason: "deleted row is canonical but no live canonical row to fall back to", fixIds: [] });
});

test("groupByPath groups matching paths", () => {
  const rows = [
    row({ id: "a".repeat(32), seoPathInfo: "/p1" }),
    row({ id: "b".repeat(32), seoPathInfo: "/p2" }),
    row({ id: "c".repeat(32), seoPathInfo: "/p1" }),
  ];
  const groups = groupByPath(rows);
  assert.deepEqual(Object.keys(groups).sort(), ["/p1", "/p2"]);
  assert.equal(groups["/p1"].length, 2);
  assert.equal(groups["/p2"].length, 1);
});

test("empty group list returns no groups", () => {
  assert.deepEqual(groupByPath([]), {});
});

Case studies

Wrong URL served

A store served an old category URL that had been soft-deleted weeks earlier

A retailer renamed a category and let Shopware write the new canonical row, but a plugin had soft-deleted the old row by setting isDeleted true without also clearing isCanonical. Because the resolver sorted the two rows without dropping the deleted one, the old path kept being handed back as canonical, and the storefront quietly served a URL that pointed at a page nobody could reach anymore.

Running the script in dry run against that sales channel and language grouped the rows by path and flagged exactly the one row that was both isDeleted and isCanonical, next to a healthy live row for the same path. Applying the fix cleared the stale flag with a single PATCH, the live row won the sort again, and the correct URL started being served without anything being deleted.

Migration leftovers

A migration soft-deleted thousands of rows but left the canonical flag set

During a platform migration, a script bulk-soft-deleted the old seo-url rows so they could be kept for audit, but it toggled isDeleted without touching isCanonical. For every path where the deleted row happened to sort ahead of the freshly written live one, the resolver picked the deleted row, and the SEO team started seeing canonical tags pointing at the pre-migration paths.

The team scheduled the script weekly in dry run first, confirmed the count of bad canonical rows matched the number of paths the migration had touched, then let it run for real. Each bad row had its isCanonical flag cleared, the live rows took over as canonical, and the small number of paths with no live row to fall back to were reported and fixed by hand.

What good looks like

After this runs on a schedule, no soft-deleted row can win the canonical sort. A path that used to serve a deleted URL now serves the live one, because the stale isCanonical flag was cleared from the row that should never have kept it. Paths where no live canonical row exists to fall back to are never guessed at, they are reported for a human to look at. The storefront starts serving the correct URL again, and nothing was ever deleted, so the soft-deleted rows are still there for audit if you need them.

FAQ

Why does a soft-deleted seo_url row get served as canonical in Shopware 6?

When more than one seo_url row exists for the same path, the sort that picks the canonical row orders by fields like isCanonical and when the row was written, but it does not push isDeleted rows to the bottom. A soft-deleted row that still carries isCanonical true, or that simply sorts ahead of the live row, can win that ordering and be handed back as the canonical URL even though it was meant to be gone.

What is the difference between isDeleted and actually deleting a seo_url row?

isDeleted is a soft-delete boolean on the seo-url entity. A row with isDeleted true is still physically present in the table and still returned by the Admin API search, it is just meant to be ignored by the resolver. Actually deleting the row removes it from the table. The bug is that a soft-deleted row is not always ignored when it should be, so the safe fix is to clear the stale isCanonical flag on it rather than delete anything.

Is it safe to clear the isCanonical flag on a soft-deleted seo_url row?

Yes, when there is a live non-deleted row for the same path to fall back to. Clearing isCanonical on a row that is already marked isDeleted only removes a flag that should never have stayed set on a soft-deleted row. The script only ever proposes clearing the flag when a healthy live canonical row exists for that same path, and it reports anything ambiguous for a human instead of touching it.

Related field notes

Citations

On the problem:

  1. Shopware, SEO URLs concept and the seo-url entity. docs.shopware.com/en/shopware-6-en/settings/seo
  2. Shopware platform, SeoUrl definition with isCanonical and isDeleted fields. github.com/shopware/shopware SeoUrlDefinition.php
  3. Shopware platform, SeoResolver that selects the canonical seo-url row. github.com/shopware/shopware SeoResolver.php

On the solution:

  1. Shopware, Admin API search endpoint and filter syntax. developer.shopware.com search criteria
  2. Shopware, Admin API authentication with client credentials. developer.shopware.com Admin API
  3. Shopware, writing and updating entities through the Admin API. developer.shopware.com writing entities

Stuck on a tricky one?

If you have a problem in Shopware order states, stock, message queue, or data integrity 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 fix a wrong canonical URL?

If this stopped a soft-deleted URL from being served, 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 Shopware field notes