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.
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.
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 line item's
coverIdis its own snapshot, separate from the product's live media. Deleting a media file checks obvious owners like the product gallery or a category, not every historic order line item that might still point at it. - The failure is silent at delete time. Nobody sees an error when the media is removed. The gap only shows up later, as a blank thumbnail in the admin or a missing image on a printed document, long after the original cleanup.
- Product images get deleted as part of normal catalog hygiene, discontinuing a product, replacing its photos, or clearing out a bulk import gone wrong, all of which can remove media that an old order still references for its cover.
- Because the reference is cosmetic, nothing about the order's price or state changes when it breaks, so it is easy to dismiss as a display bug instead of tracing it back to a dangling
coverId.
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.
Build it step by step
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.
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
// 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
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.
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 {}
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) : {};
}
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.
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)
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);
}
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.
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", [])}
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));
}
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.
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"
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";
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- Shopware Developer Documentation: the Data Abstraction Layer and cascade delete behavior for foreign keys. developer.shopware.com data-abstraction-layer
- Shopware Admin API Reference: the OrderLineItem entity, including the cover association. shopware.stoplight.io admin-api order-line-item
- Shopware Admin API Reference: the Media entity. shopware.stoplight.io admin-api media
On the solution:
- Shopware Developer Documentation: Search Criteria, admin API filtering with equals, equalsAny, and not. developer.shopware.com search-criteria
- Shopware Developer Documentation: Admin API concepts. developer.shopware.com admin-api
- 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.
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