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.
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.
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:
- Hard-deleting a product is a normal, supported action in the Administration and via the Admin API. Shopware does not warn you that the product has already shipped in past orders.
- The cascade is silent. There is no log entry, no flow trigger, and no admin notification when a historic line item loses its product reference. You only find out when someone tries to touch that order again.
- The failure is delayed and looks unrelated. A merchant reports "I can't edit this old order" months after the product that broke it was deleted, so the two events are rarely connected without digging into the database.
- The line item's
payloadfield, a JSON snapshot taken when the order was placed, is not automatically refreshed when the product changes or disappears, so it can still show the original product number even though the live reference is gone. This is tracked upstream as shopware/shopware#3594.
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.
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 the search 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 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.
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)
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);
}
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.
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", [])
]
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,
}));
}
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.
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"]}
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 };
}
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.
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.
"""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()
/**
* 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.
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"}
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
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.
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.
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:
- 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
- Shopware Developer Documentation: Removing Associated Data. developer.shopware.com deleting-associated-data
- 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:
- Shopware Admin API Reference: the OrderLineItem entity. shopware.stoplight.io admin-api order-line-item
- Shopware Developer Documentation: Search Criteria. developer.shopware.com search-criteria
- 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.
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