Reconciler Order Data Integrity

Shopware 6 order line item orphaned after its product is deleted

Someone hard-deletes a product that has been sold before. Nothing looks wrong at first. Then, weeks later, a support agent tries to edit the quantity on an old order and the save fails with a foreign-key or type error. The order is frozen. The cause is a database cascade that nulled out the line item's link to the product the moment it was deleted, while Shopware's own entity contract still assumes that link can never be null. Here is why it happens and a small script that finds the orphaned line items and relinks only the ones it can match with confidence.

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

In Shopware 6, order_line_item.product_id and product_version_id are foreign keys to the product table with an ON DELETE SET NULL cascade. Hard-deleting a product nulls both columns on every historic order line item that referenced it, even though OrderLineItemEntity::getProductVersionId() is typed to always return a string. Later writes to that order then throw. Run a small Python or Node.js script that searches order-line-item for product-type rows with a null productId, checks the line item's stored payload.productNumber against live products with the same number, and relinks only when there is exactly one confident match. Anything else gets flagged for a human, never guessed. Full code, tests, and sources are below.

The problem in plain words

An order line item in Shopware 6 is not just a description and a price. When the order type is product, the line item also carries a foreign key back to the product it came from, plus the version of that product at the time. That link is what lets Shopware recalculate totals, resolve stock, and re-render the order in the Administration.

Products get deleted for all sorts of ordinary reasons. A merchant cleans up a discontinued line, an import script removes duplicates, or someone in the team hard-deletes a test product that had already gone through checkout once. The database itself is honest about what happens next: the foreign key is set up with ON DELETE SET NULL, so MySQL walks every order line item that still points at the deleted product and quietly sets product_id and product_version_id to null. The row still exists. The order still exists. But the link is gone.

The trouble is that Shopware's own code was not written expecting that null. OrderLineItemDefinition declares productVersionId as a ReferenceVersionField, which the DAL treats as required, and the generated entity's getProductVersionId() method is typed to return a plain string, never string|null. So the database and the application disagree about whether this value can be empty. Nothing notices immediately, because reading the order for display mostly works. It is the next write, a quantity change, a recalculation, or an order-edit save, that hits the mismatch and throws.

Product hard-deleted ON DELETE SET NULL on order_line_item fk cascade fires productId and productVersionId now null in DB Next order write Entity contract still expects productVersionId as a required, non-null string so the write throws and the order is frozen
The database cascade nulls the link honestly. The order entity contract still assumes it cannot be null, so the mismatch only surfaces on the next write.

Why it happens

This is a known gap between how Shopware models the reference and how the database enforces referential integrity when a product is removed. A few things make it easy to hit:

The key insight

The database did exactly what its foreign key told it to do. The bug is that the application layer never agreed to allow that null. So the fix is not to patch the database constraint, it is to detect the rows the cascade already broke and repair only the ones where we can prove, with the line item's own stored data, which product it used to point at. When we cannot prove it, we say so instead of guessing.

The fix, as a flow

We do not touch the order's stateId or any state machine transition here, this is purely a data integrity repair on the line item's product reference. The script searches for line items whose type is product but whose productId is null, reads each one's stored payload.productNumber, and looks for a live product with that same number. Exactly one match means a safe relink. Zero or more than one match means the line item gets flagged for a merchant to look at by hand.

Search order-line-item type=product, productId=null Read payload productNumber, referencedId Search product by productNumber Exactly one match? yes no, flag for review PATCH order-line-item productId, productVersionId
The script relinks only when the payload's product number matches exactly one live product. Zero matches or more than one match is reported for a merchant to review by hand, never guessed.

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 write
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 write
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 the repair PATCH.

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

Find the orphaned line items

Search order-line-item for rows where type equals product and productId equals null, and include the order association so each hit carries its parent order id and order number. We page through with page and limit so a large backlog is handled safely.

step3.py
def find_orphaned_line_items(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "type", "value": "product"},
            {"type": "equals", "field": "productId", "value": None},
        ],
        "associations": {"order": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/order-line-item", token, body)
step3.js
async function findOrphanedLineItems(token, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "type", value: "product" },
      { type: "equals", field: "productId", value: null },
    ],
    associations: { order: {} },
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/order-line-item", token, body);
}
4

Look for a live product with the same number

Each orphaned line item still has its original payload JSON, taken when the order was placed. Pull payload.productNumber out of it and search product for that exact number. This tells you whether a matching product was re-created after the deletion, or is truly gone.

step4.py
def find_candidate_products(token, product_number):
    body = {"filter": [{"type": "equals", "field": "productNumber", "value": product_number}]}
    data = api("POST", "/api/search/product", token, body)
    return [
        {"id": p["id"], "versionId": p.get("versionId", p.get("id")), "productNumber": p["productNumber"]}
        for p in data.get("data", [])
    ]
step4.js
async function findCandidateProducts(token, productNumber) {
  const body = { filter: [{ type: "equals", field: "productNumber", value: productNumber }] };
  const data = await api("POST", "/api/search/product", token, body);
  return (data.data || []).map((p) => ({
    id: p.id,
    versionId: p.versionId || p.id,
    productNumber: p.productNumber,
  }));
}
5

Decide, with one pure function

Keep the decision out of the network code entirely. This function takes the already-fetched line item and its candidate product list and returns one of three plain outcomes: skip when the row was never actually orphaned, flag with a reason when we cannot be confident, or relink with the exact id and version to write. It never guesses at a product id.

decide.py
def decide_line_item_repair(line_item, candidate_products):
    if line_item.get("type") != "product" or line_item.get("productId") is not None:
        return {"action": "skip"}
    if len(candidate_products) == 0:
        return {"action": "flag", "reason": "no-match"}
    if len(candidate_products) > 1:
        return {"action": "flag", "reason": "ambiguous-match"}
    match = candidate_products[0]
    return {"action": "relink", "productId": match["id"], "productVersionId": match["versionId"]}
decide.js
export function decideLineItemRepair(lineItem, candidateProducts) {
  if (lineItem.type !== "product" || lineItem.productId !== null) {
    return { action: "skip" };
  }
  if (candidateProducts.length === 0) {
    return { action: "flag", reason: "no-match" };
  }
  if (candidateProducts.length > 1) {
    return { action: "flag", reason: "ambiguous-match" };
  }
  const match = candidateProducts[0];
  return { action: "relink", productId: match.id, productVersionId: match.versionId };
}
6

Relink, flag, or skip, guarded by dry run

When the decision is relink and DRY_RUN is off, PATCH /api/order-line-item/{id} with the new productId and productVersionId. When it is flag, log the order id, order number, line item id, description, and payload snapshot for a merchant to review by hand. Never touch stateId here. If the order itself needs a workflow nudge after the repair, that goes through the state machine transition endpoints, not a manual PATCH.

Run it safe

Always start with DRY_RUN=true. A relink is a claim that this line item always meant this product. Only write it when the candidate list has exactly one entry. Anything less certain belongs in the flagged report, not in the database.

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 relinks line items with exactly one confident match and leaves everything else for a human.

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

repair_orphaned_line_items.py
"""Find Shopware 6 order line items orphaned by a hard-deleted product and repair them, safely.

order_line_item.product_id and product_version_id are foreign keys to product with an
ON DELETE SET NULL cascade. Deleting a product nulls both columns on every historic line
item that referenced it, but the entity contract still expects productVersionId to be a
non-null string, so a later write to that order can throw. This script finds the orphaned
rows, checks each one's stored payload.productNumber against live products, relinks only
when exactly one confident match exists, and flags everything else for manual review.
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("repair_orphaned_line_items")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
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 find_orphaned_line_items(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "type", "value": "product"},
            {"type": "equals", "field": "productId", "value": None},
        ],
        "associations": {"order": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/order-line-item", token, body)


def find_candidate_products(token, product_number):
    body = {"filter": [{"type": "equals", "field": "productNumber", "value": product_number}]}
    data = api("POST", "/api/search/product", token, body)
    return [
        {"id": p["id"], "versionId": p.get("versionId", p.get("id")), "productNumber": p["productNumber"]}
        for p in data.get("data", [])
    ]


def decide_line_item_repair(line_item, candidate_products):
    if line_item.get("type") != "product" or line_item.get("productId") is not None:
        return {"action": "skip"}
    if len(candidate_products) == 0:
        return {"action": "flag", "reason": "no-match"}
    if len(candidate_products) > 1:
        return {"action": "flag", "reason": "ambiguous-match"}
    match = candidate_products[0]
    return {"action": "relink", "productId": match["id"], "productVersionId": match["versionId"]}


def relink_line_item(token, line_item_id, product_id, product_version_id):
    api(
        "PATCH",
        f"/api/order-line-item/{line_item_id}",
        token,
        {"productId": product_id, "productVersionId": product_version_id},
    )


def run():
    token = get_token()
    relinked = 0
    flagged = 0
    page = 1
    while True:
        data = find_orphaned_line_items(token, page=page)
        rows = data.get("data", [])
        if not rows:
            break
        for row in rows:
            payload = row.get("payload") or {}
            product_number = payload.get("productNumber")
            candidates = find_candidate_products(token, product_number) if product_number else []
            decision = decide_line_item_repair(row, candidates)

            order = (row.get("order") or {}) if isinstance(row.get("order"), dict) else {}

            if decision["action"] == "skip":
                continue

            if decision["action"] == "flag":
                log.warning(
                    "FLAG order=%s lineItem=%s reason=%s description=%r payload=%s",
                    order.get("orderNumber", "?"), row["id"], decision["reason"],
                    row.get("label") or row.get("description"), payload,
                )
                flagged += 1
                continue

            log.info(
                "Order %s lineItem %s eligible for relink to product %s. %s",
                order.get("orderNumber", "?"), row["id"], decision["productId"],
                "would relink" if DRY_RUN else "relinking",
            )
            if not DRY_RUN:
                relink_line_item(token, row["id"], decision["productId"], decision["productVersionId"])
            relinked += 1

        if page * 500 >= data.get("total", 0):
            break
        page += 1

    log.info(
        "Done. %d line item(s) %s, %d flagged for manual review.",
        relinked, "to relink" if DRY_RUN else "relinked", flagged,
    )


if __name__ == "__main__":
    run()
repair-orphaned-line-items.js
/**
 * Find Shopware 6 order line items orphaned by a hard-deleted product and repair them, safely.
 *
 * order_line_item.product_id and product_version_id are foreign keys to product with an
 * ON DELETE SET NULL cascade. Deleting a product nulls both columns on every historic line
 * item that referenced it, but the entity contract still expects productVersionId to be a
 * non-null string, so a later write to that order can throw. This script finds the orphaned
 * rows, checks each one's stored payload.productNumber against live products, relinks only
 * when exactly one confident match exists, and flags everything else for manual review.
 * 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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decideLineItemRepair(lineItem, candidateProducts) {
  if (lineItem.type !== "product" || lineItem.productId !== null) {
    return { action: "skip" };
  }
  if (candidateProducts.length === 0) {
    return { action: "flag", reason: "no-match" };
  }
  if (candidateProducts.length > 1) {
    return { action: "flag", reason: "ambiguous-match" };
  }
  const match = candidateProducts[0];
  return { action: "relink", productId: match.id, productVersionId: match.versionId };
}

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 findOrphanedLineItems(token, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "type", value: "product" },
      { type: "equals", field: "productId", value: null },
    ],
    associations: { order: {} },
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/order-line-item", token, body);
}

async function findCandidateProducts(token, productNumber) {
  const body = { filter: [{ type: "equals", field: "productNumber", value: productNumber }] };
  const data = await api("POST", "/api/search/product", token, body);
  return (data.data || []).map((p) => ({
    id: p.id,
    versionId: p.versionId || p.id,
    productNumber: p.productNumber,
  }));
}

async function relinkLineItem(token, lineItemId, productId, productVersionId) {
  await api("PATCH", `/api/order-line-item/${lineItemId}`, token, {
    productId,
    productVersionId,
  });
}

export async function run() {
  const token = await getToken();
  let relinked = 0;
  let flagged = 0;
  let page = 1;

  while (true) {
    const data = await findOrphanedLineItems(token, page);
    const rows = data.data || [];
    if (rows.length === 0) break;

    for (const row of rows) {
      const payload = row.payload || {};
      const productNumber = payload.productNumber;
      const candidates = productNumber ? await findCandidateProducts(token, productNumber) : [];
      const decision = decideLineItemRepair(row, candidates);
      const order = row.order || {};

      if (decision.action === "skip") continue;

      if (decision.action === "flag") {
        console.warn(
          `FLAG order=${order.orderNumber || "?"} lineItem=${row.id} reason=${decision.reason} description=${JSON.stringify(row.label || row.description)} payload=${JSON.stringify(payload)}`
        );
        flagged++;
        continue;
      }

      console.log(
        `Order ${order.orderNumber || "?"} lineItem ${row.id} eligible for relink to product ${decision.productId}. ${DRY_RUN ? "would relink" : "relinking"}`
      );
      if (!DRY_RUN) await relinkLineItem(token, row.id, decision.productId, decision.productVersionId);
      relinked++;
    }

    if (page * 500 >= (data.total || 0)) break;
    page++;
  }

  console.log(`Done. ${relinked} line item(s) ${DRY_RUN ? "to relink" : "relinked"}, ${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 rule is the part most worth testing, because it decides whether a historic financial document gets rewritten. Because decide_line_item_repair is pure, no network and no Shopware instance is needed. It just takes plain objects in and checks the answer.

test_orphaned_repair.py
from repair_orphaned_line_items import decide_line_item_repair


def line_item(**over):
    base = {"id": "li-1", "type": "product", "productId": None, "payload": {"productNumber": "SW1000"}}
    base.update(over)
    return base


def product(**over):
    base = {"id": "prod-1", "versionId": "ver-1", "productNumber": "SW1000"}
    base.update(over)
    return base


def test_relink_when_exactly_one_candidate():
    result = decide_line_item_repair(line_item(), [product()])
    assert result == {"action": "relink", "productId": "prod-1", "productVersionId": "ver-1"}


def test_flag_no_match_when_no_candidates():
    result = decide_line_item_repair(line_item(), [])
    assert result == {"action": "flag", "reason": "no-match"}


def test_flag_ambiguous_match_when_multiple_candidates():
    result = decide_line_item_repair(line_item(), [product(), product(id="prod-2", versionId="ver-2")])
    assert result == {"action": "flag", "reason": "ambiguous-match"}


def test_skip_when_not_product_type():
    result = decide_line_item_repair(line_item(type="promotion"), [product()])
    assert result == {"action": "skip"}


def test_skip_when_product_id_already_set():
    result = decide_line_item_repair(line_item(productId="prod-1"), [product()])
    assert result == {"action": "skip"}
orphaned-repair.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideLineItemRepair } from "./repair-orphaned-line-items.js";

const lineItem = (over = {}) => ({
  id: "li-1",
  type: "product",
  productId: null,
  payload: { productNumber: "SW1000" },
  ...over,
});

const product = (over = {}) => ({ id: "prod-1", versionId: "ver-1", productNumber: "SW1000", ...over });

test("relink when exactly one candidate", () => {
  const result = decideLineItemRepair(lineItem(), [product()]);
  assert.deepEqual(result, { action: "relink", productId: "prod-1", productVersionId: "ver-1" });
});

test("flag no-match when no candidates", () => {
  const result = decideLineItemRepair(lineItem(), []);
  assert.deepEqual(result, { action: "flag", reason: "no-match" });
});

test("flag ambiguous-match when multiple candidates", () => {
  const result = decideLineItemRepair(lineItem(), [product(), product({ id: "prod-2", versionId: "ver-2" })]);
  assert.deepEqual(result, { action: "flag", reason: "ambiguous-match" });
});

test("skip when not product type", () => {
  const result = decideLineItemRepair(lineItem({ type: "promotion" }), [product()]);
  assert.deepEqual(result, { action: "skip" });
});

test("skip when productId already set", () => {
  const result = decideLineItemRepair(lineItem({ productId: "prod-1" }), [product()]);
  assert.deepEqual(result, { action: "skip" });
});

Case studies

Product cleanup

A discontinued line was deleted, and old orders froze

A homeware store discontinued a product line and hard-deleted every product in it during a catalog cleanup. Weeks later, a support agent tried to adjust the quantity on a customer's return and the save failed with a foreign-key error. Nobody connected the two events until someone queried order_line_item directly and found dozens of rows with a null productId.

Running the script in dry run surfaced every orphaned line item across the whole order history in one pass, most of them from that one cleanup. None of the deleted products had been re-created, so all of them came back as no-match and went into a review list instead of being silently relinked to the wrong thing.

Re-imported catalog

A product was deleted and re-created with the same number

An import script deleted and re-inserted a batch of products during a nightly sync, giving the new rows fresh ids but keeping the same product numbers. Every historic order that referenced the old ids lost its product link overnight, and the next attempted edit on any of those orders threw the same type error.

Because the re-created products kept identical product numbers, the script's product-number lookup found exactly one live match for each orphaned line item. With DRY_RUN=false, it relinked all of them safely, based only on the stored payload, and left the small handful with ambiguous matches for a merchant to confirm by hand.

What good looks like

After this runs, orphaned line items stop being a surprise that only shows up when someone tries to edit an old order. The confident matches get relinked automatically, and everything uncertain lands in a clear report with the order id, order number, line item id, and payload snapshot, so a merchant can decide instead of the script guessing. No historic financial document gets rewritten on a hunch.

FAQ

Why does deleting a product break old orders in Shopware 6?

order_line_item.product_id and product_version_id are foreign keys to the product table with an ON DELETE SET NULL cascade. Hard-deleting a product nulls both columns on every historic line item that referenced it, even though the entity contract treats productVersionId as required, so any later write to that order can throw a type or constraint error.

Is it safe to automatically relink an orphaned order line item to a product?

Only when there is exactly one confident match, found by comparing the orphaned line item's stored payload.productNumber against live products with the same number. If zero or more than one product matches, the safe move is to flag the line item for manual merchant review rather than guess, because relinking a historic financial document to the wrong product corrupts accounting and tax history.

How do I find orphaned order line items with the Shopware Admin API?

Authenticate with POST /api/oauth/token using client_credentials, then POST /api/search/order-line-item with a filter for type equals product and productId equals null, including the order association. Each hit still has its original payload JSON, which usually contains a productNumber you can use to look for a live replacement product.

Related field notes

Citations

On the problem:

  1. GitHub Issue: product_version_id is not nullable in the OrderLineItem definition, but is null after deleting a product. github.com/shopware/shopware/issues/3594
  2. Shopware Developer Documentation: Removing Associated Data. developer.shopware.com deleting-associated-data
  3. Shopware Community Forum: Payload of order line item is not updated. forum.shopware.com payload-of-order-line-item-is-not-updated

On the solution:

  1. Shopware Admin API Reference: the OrderLineItem entity. shopware.stoplight.io admin-api order-line-item
  2. Shopware Developer Documentation: Search Criteria. developer.shopware.com search-criteria
  3. Shopware Admin API Reference: Reading Entities. shopware.stoplight.io admin-api reading-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 unfreeze an order?

If this saved you a support ticket or a corrupted order, 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