Reconciler Order Data Integrity

Shopware 6 order line item cover references deleted media

Someone tidies up the Media Library, removes a batch of images that look unused, and everything seems fine. Then an old order shows a broken thumbnail in the admin, or a generated invoice PDF is missing the product picture it used to have. The image was never really unused. An order line item still had its own cover pointing straight at it, and deleting the media file from the library does not check that reference the way deleting a product does. Here is why it happens and a small script that finds the dangling references and clears only the ones that carry no financial risk.

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

Every order-line-item can carry its own coverId, a foreign key to a media entity that was snapshotted at the time the order was placed. That reference is separate from the product's current cover image, and deleting a media file from the Media Library does not check whether an order line item still points at it. So a routine cleanup can hard-delete the media row while coverId on the line item still holds its id, and the admin, storefront, and any generated document then fail to resolve a cover that no longer exists. Run a small Python or Node.js script that searches order-line-item for rows with a coverId set, cross-checks every id against live media rows, and clears the dangling ones. Because a cover is a decorative thumbnail with no price attached, clearing it is always safe once the reference is confirmed dangling, no manual review branch needed. Full code, tests, and sources are below.

The problem in plain words

An order line item in Shopware 6 does not just remember a product id and a price. It also stores its own cover, a snapshot association pointing at a media entity, captured at the moment the order was placed. That is deliberate: if the product's image changes or the product itself is deleted later, the order should still be able to show the picture the customer actually saw when they bought it.

The trouble is that this snapshot is just a foreign key, coverId, sitting on the line item row. The Media Library's delete action checks whether a media file is attached to a product, a category, or a few other well-known places, but it does not walk every historic order line item looking for a reference before it lets the delete go through. Select an old product image that looks unused because the product was discontinued, delete it from the library, and the media row is gone even though a line item on a real order still points at it.

Nothing crashes immediately. The order's price, quantity, and tax are untouched, because those live in the line item's own columns and payload, not in the media entity. What breaks is anything that tries to render the cover: the admin's order detail view shows a blank or broken thumbnail where the product picture used to be, and a document generator building an invoice or delivery note that includes the line item image gets nothing back for that association and either drops the image or errors depending on the template.

Delete "unused" media file from the library Delete does not check order line item cover references Media row hard-deleted still referenced Broken cover in order order_line_item.coverId still points at the deleted row so the admin and documents cannot resolve the thumbnail
The Media Library's delete action does not walk historic order line items before removing a file. A cover reference can be left dangling long after the delete completes without error.

Why it happens

This is a gap between where Shopware checks for references and where the reference actually lives. A few things make it easy to hit:

The key insight

A dangling promotion reference can carry a real discount amount, so clearing it needs care. A dangling cover reference never does. coverId only controls which thumbnail renders next to the line item, it has no bearing on unitPrice, totalPrice, or the order's amountTotal. That means the safe move here is simpler than the promotion case: once we confirm the media truly does not exist, clearing coverId is always safe, with no manual review branch needed.

The fix, as a flow

We do not touch stateId or run any state machine transition here, this is purely a data integrity repair on the line item's cover reference. The script searches order-line-item for rows where coverId is set, collects every coverId, and checks which of those ids still exist in media. Any id that does not resolve gets its coverId cleared on the line item.

Search order-line-item coverId is not null Collect coverIds from every hit Search media equalsAny id list Media still exists? no, dangling PATCH order-line-item null the coverId no price touched yes, leave it
The script clears a dangling coverId as soon as the media is confirmed missing. There is no price to protect, so unlike a dangling promotion reference, no case here needs a human review step.

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 both searches 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 every line item with a cover set

Search order-line-item for rows where coverId is not null, and include the order association so each hit carries its parent order and order state. We page through with page and limit so a large backlog is handled safely.

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

Check which coverIds still exist

Collect every distinct coverId from the hits and search media with an equalsAny filter on that list. Any id from the first set that is missing from this result is a dangling reference, the media file behind it is gone.

step4.py
def find_existing_media_ids(token, media_ids):
    if not media_ids:
        return set()
    body = {
        "filter": [{"type": "equalsAny", "field": "id", "value": list(media_ids)}],
        "total-count-mode": 1,
    }
    data = api("POST", "/api/search/media", token, body)
    return {m["id"] for m in data.get("data", [])}
step4.js
async function findExistingMediaIds(token, mediaIds) {
  if (mediaIds.length === 0) return new Set();
  const body = {
    filter: [{ type: "equalsAny", field: "id", value: mediaIds }],
    "total-count-mode": 1,
  };
  const data = await api("POST", "/api/search/media", token, body);
  return new Set((data.data || []).map((m) => m.id));
}
5

Decide, with one pure function

Keep the decision out of the network code entirely. This function takes an already-fetched line item and the set of media ids that still exist, and returns one of two plain outcomes: ok when there is no cover reference to worry about, or safe_to_clear when the line item's coverId points at a media row that no longer exists. There is no needs_manual_review outcome here, because a cover carries no price.

decide.py
def classify_dangling_cover_line_item(line_item, existing_media_ids):
    cover_id = line_item.get("coverId")
    if cover_id is None:
        return "ok"
    if cover_id in existing_media_ids:
        return "ok"
    return "safe_to_clear"
decide.js
export function classifyDanglingCoverLineItem(lineItem, existingMediaIds) {
  const coverId = lineItem.coverId ?? null;
  if (coverId === null) return "ok";
  if (existingMediaIds.has(coverId)) return "ok";
  return "safe_to_clear";
}
6

Clear the dangling cover, guarded by dry run

When the decision is safe_to_clear and DRY_RUN is off, PATCH /api/order-line-item/{id} with {"coverId": null}. That removes the broken foreign key without touching unitPrice, totalPrice, quantity, or anything else on the line item. Never touch an order whose stateMachineState.technicalName is cancelled unless someone explicitly asks for it, and never PATCH stateId directly, state changes always go through the state machine transition endpoints.

Run it safe

Always start with DRY_RUN=true. Clearing a dangling coverId is low risk because the cover carries no price, but confirm the media truly is missing, not just slow to load, before you write. Checking against a live media search rather than assuming a null response means the client only clears references that are actually dangling.

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 clears references confirmed to be dangling.

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

repair_deleted_media_cover_references.py
"""Find Shopware 6 order line items with a dangling coverId and repair them, safely.

An order line item stores its own coverId, a foreign key to a media entity snapshotted
at the time the order was placed, separate from the product's current image. Deleting a
media file from the Media Library does not check whether an order line item still
references it, so a routine cleanup can hard-delete a media row while coverId on a line
item still points at it. The admin, storefront, and generated documents then fail to
resolve that cover. This script finds the dangling references and clears coverId once the
media is confirmed missing. Because a cover carries no price, clearing is always safe,
there is no manual review branch. 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_deleted_media_cover_references")

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_line_items_with_cover(token, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [{"type": "not", "operator": "or",
                    "queries": [{"type": "equals", "field": "coverId", "value": None}]}],
        "associations": {"order": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/order-line-item", token, body)


def find_existing_media_ids(token, media_ids):
    if not media_ids:
        return set()
    body = {
        "filter": [{"type": "equalsAny", "field": "id", "value": list(media_ids)}],
        "total-count-mode": 1,
    }
    data = api("POST", "/api/search/media", token, body)
    return {m["id"] for m in data.get("data", [])}


def classify_dangling_cover_line_item(line_item, existing_media_ids):
    cover_id = line_item.get("coverId")
    if cover_id is None:
        return "ok"
    if cover_id in existing_media_ids:
        return "ok"
    return "safe_to_clear"


def clear_dangling_cover(token, line_item_id):
    api(
        "PATCH",
        f"/api/order-line-item/{line_item_id}",
        token,
        {"coverId": None},
    )


def run():
    token = get_token()
    cleared = 0
    page = 1

    while True:
        data = find_line_items_with_cover(token, page=page)
        rows = data.get("data", [])
        if not rows:
            break

        cover_ids = {row.get("coverId") for row in rows if row.get("coverId")}
        existing_ids = find_existing_media_ids(token, cover_ids)

        for row in rows:
            decision = classify_dangling_cover_line_item(row, existing_ids)
            if decision == "ok":
                continue

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

            if state == "cancelled":
                log.info("Order %s is cancelled, leaving lineItem %s alone.", order.get("orderNumber", "?"), row["id"])
                continue

            log.info(
                "Order %s lineItem %s has a dangling coverId. %s",
                order.get("orderNumber", "?"), row["id"],
                "would clear" if DRY_RUN else "clearing",
            )
            if not DRY_RUN:
                clear_dangling_cover(token, row["id"])
            cleared += 1

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

    log.info("Done. %d line item(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")


if __name__ == "__main__":
    run()
repair-deleted-media-cover-references.js
/**
 * Find Shopware 6 order line items with a dangling coverId and repair them, safely.
 *
 * An order line item stores its own coverId, a foreign key to a media entity snapshotted
 * at the time the order was placed, separate from the product's current image. Deleting a
 * media file from the Media Library does not check whether an order line item still
 * references it, so a routine cleanup can hard-delete a media row while coverId on a line
 * item still points at it. The admin, storefront, and generated documents then fail to
 * resolve that cover. This script finds the dangling references and clears coverId once
 * the media is confirmed missing. Because a cover carries no price, clearing is always
 * safe, there is no manual review branch. 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 classifyDanglingCoverLineItem(lineItem, existingMediaIds) {
  const coverId = lineItem.coverId ?? null;
  if (coverId === null) return "ok";
  if (existingMediaIds.has(coverId)) return "ok";
  return "safe_to_clear";
}

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

async function findExistingMediaIds(token, mediaIds) {
  if (mediaIds.length === 0) return new Set();
  const body = {
    filter: [{ type: "equalsAny", field: "id", value: mediaIds }],
    "total-count-mode": 1,
  };
  const data = await api("POST", "/api/search/media", token, body);
  return new Set((data.data || []).map((m) => m.id));
}

async function clearDanglingCover(token, lineItemId) {
  await api("PATCH", `/api/order-line-item/${lineItemId}`, token, {
    coverId: null,
  });
}

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

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

    const coverIds = [...new Set(rows.map((row) => row.coverId).filter(Boolean))];
    const existingIds = await findExistingMediaIds(token, coverIds);

    for (const row of rows) {
      const decision = classifyDanglingCoverLineItem(row, existingIds);
      if (decision === "ok") continue;

      const order = row.order || {};
      const state = order.stateMachineState ? order.stateMachineState.technicalName : null;

      if (state === "cancelled") {
        console.log(`Order ${order.orderNumber || "?"} is cancelled, leaving lineItem ${row.id} alone.`);
        continue;
      }

      console.log(
        `Order ${order.orderNumber || "?"} lineItem ${row.id} has a dangling coverId. ${DRY_RUN ? "would clear" : "clearing"}`
      );
      if (!DRY_RUN) await clearDanglingCover(token, row.id);
      cleared++;
    }

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

  console.log(`Done. ${cleared} line item(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}

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 order gets rewritten. Because classify_dangling_cover_line_item is pure, no network and no Shopware instance is needed. It just takes plain objects in and checks the answer.

test_deleted_media_cover_repair.py
from repair_deleted_media_cover_references import classify_dangling_cover_line_item


def line_item(**over):
    base = {
        "id": "li-1",
        "type": "product",
        "coverId": "media-1",
    }
    base.update(over)
    return base


def test_ok_when_cover_still_exists():
    result = classify_dangling_cover_line_item(line_item(), {"media-1", "media-2"})
    assert result == "ok"


def test_ok_when_no_cover_id():
    result = classify_dangling_cover_line_item(line_item(coverId=None), set())
    assert result == "ok"


def test_safe_to_clear_when_dangling():
    result = classify_dangling_cover_line_item(line_item(), {"media-2"})
    assert result == "safe_to_clear"


def test_safe_to_clear_when_no_media_exists_at_all():
    result = classify_dangling_cover_line_item(line_item(), set())
    assert result == "safe_to_clear"
deleted-media-cover-repair.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyDanglingCoverLineItem } from "./repair-deleted-media-cover-references.js";

const lineItem = (over = {}) => ({
  id: "li-1",
  type: "product",
  coverId: "media-1",
  ...over,
});

test("ok when cover still exists", () => {
  const result = classifyDanglingCoverLineItem(lineItem(), new Set(["media-1", "media-2"]));
  assert.equal(result, "ok");
});

test("ok when no coverId", () => {
  const result = classifyDanglingCoverLineItem(lineItem({ coverId: null }), new Set());
  assert.equal(result, "ok");
});

test("safe_to_clear when dangling", () => {
  const result = classifyDanglingCoverLineItem(lineItem(), new Set(["media-2"]));
  assert.equal(result, "safe_to_clear");
});

test("safe_to_clear when no media exists at all", () => {
  const result = classifyDanglingCoverLineItem(lineItem(), new Set());
  assert.equal(result, "safe_to_clear");
});

Case studies

Catalog cleanup

A discontinued product's old photos left broken thumbnails on past orders

A furniture store discontinued a product line and cleared out its images from the Media Library to free up storage, since the product itself had already been removed from the catalog. Weeks later, a support agent pulled up an old order to answer a warranty question and saw a blank square where the product thumbnail used to be.

Running the script in dry run found a batch of order line items whose coverId no longer resolved to a live media row. None of them carried any price impact, since the cover is purely cosmetic, so every one was safe to clear on the next run and the admin stopped showing broken thumbnails on that order history.

Invoice generation

A generated invoice PDF silently dropped the product image

An electronics retailer generates PDF invoices that include a small product thumbnail per line item. After a batch image cleanup in the Media Library, one customer's re-issued invoice came out with an empty image slot where the product picture belonged, and the support team could not tell why from the document template alone.

Tracing it back to the order-line-item level showed a dangling coverId from the same cleanup. Because clearing a cover reference never touches price or tax, the fix was applied without any manual review step, and the next invoice regeneration rendered cleanly once the stale reference was gone.

What good looks like

After this runs, a routine Media Library cleanup stops leaving silent, hard-to-trace gaps in old orders. Dangling cover references get cleared automatically because there is nothing financial riding on them, admin order views and generated documents stop failing to resolve a thumbnail that no longer exists, and nobody has to trace a blank product image back through the media table by hand.

FAQ

Why does a Shopware 6 order show a broken cover image after a Media Library cleanup?

An order line item stores its own coverId pointing at a media entity, separate from the product's current image. If someone deletes that media file from the Media Library because it looks unused, Shopware does not block the delete on the strength of an order line item reference, so the media row is hard-deleted while coverId on the line item still points at it. The admin and any document or storefront view that tries to resolve that cover then gets nothing back.

Is it safe to automatically clear a dangling coverId on an order line item?

Yes, clearing coverId is always safe once the reference is confirmed dangling, because the cover is a decorative thumbnail association with no price, tax, or quantity attached to it. Nulling coverId does not change totalPrice, unitPrice, or amountTotal on the order, it only stops the admin and documents from trying to resolve a media row that no longer exists.

How do I find order line items with a dangling coverId using the Shopware Admin API?

Authenticate with POST /api/oauth/token using client_credentials, then POST /api/search/order-line-item with a filter for coverId is not null, including the cover association. Collect every coverId from the hits, then POST /api/search/media with an equalsAny filter on those ids. Any coverId missing from that second result set, or whose cover association came back null, is a dangling reference.

Related field notes

Citations

On the problem:

  1. Shopware Developer Documentation: the Data Abstraction Layer and cascade delete behavior for foreign keys. developer.shopware.com data-abstraction-layer
  2. Shopware Admin API Reference: the OrderLineItem entity, including the cover association. shopware.stoplight.io admin-api order-line-item
  3. Shopware Admin API Reference: the Media entity. shopware.stoplight.io admin-api media

On the solution:

  1. Shopware Developer Documentation: Search Criteria, admin API filtering with equals, equalsAny, and not. developer.shopware.com search-criteria
  2. Shopware Developer Documentation: Admin API concepts. developer.shopware.com admin-api
  3. Shopware Developer Documentation: writing entities with PATCH on the Admin API. developer.shopware.com crud

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 clear a broken thumbnail?

If this saved you a confusing support ticket or a blank invoice image, 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