Reconciler Order State Machine
Delivery or transaction stateMachineState returns null
You search an order, read the delivery or the transaction, and stateMachineState comes back null while stateId sits there looking like a perfectly normal foreign key. The order is not broken. It might not even be a bug. Most of the time it is the Admin API quietly skipping an association you did not ask for, and only sometimes is it a state row that really has gone missing. Here is how to tell the two apart before you touch anything, and a small script that only repairs the second case.
Since Shopware 6.6, stateMachineState on order_delivery and order_transaction is a lazy-loaded association that the Admin API omits by default. A plain search or read gives you a valid stateId but a null stateMachineState object even though the state row exists, which is a confirmed regression tracked as shopware/shopware#3717. Re-fetch with associations.stateMachineState explicitly requested. If it resolves, nothing is wrong, you just needed to ask for it. If it is still null after that, look up the raw stateId against state-machine-state directly. Zero results back means the foreign key is genuinely orphaned, from a stale migration, a custom import, or a direct SQL edit that bypassed the state machine, and the fix is to relink it by calling the proper transition action, never a raw stateId PATCH. Full code, tests, and sources are below.
The problem in plain words
Every order_delivery and every order_transaction carries a stateId, a foreign key into state_machine_state, and a stateMachineState association that is supposed to resolve that key into the actual object with a technicalName like shipped or paid. An integration reading orders usually wants that technicalName, not the raw UUID, so it reads stateMachineState.technicalName and expects a string back.
Instead, on a lot of Shopware 6.6 and later stores, that read comes back null. The stateId is right there, a normal-looking 32 character hex value, but the joined object is empty. Code that assumed the association would always be populated throws on the null dereference, or silently treats the delivery as unshipped and the transaction as unpaid when neither is true. The order itself is fine. What broke is either the request that fetched it, or, less often, the row the request was pointing at.
Why it happens
There are two separate causes that surface as the exact same symptom, and you cannot tell them apart from a single default read:
- The far more common cause: since Shopware 6.6,
stateMachineStateis a lazy-loaded association onorder_deliveryandorder_transaction, so the Admin API leaves it out of the response unless the caller explicitly asks forassociations.deliveries.stateMachineStateorassociations.transactions.stateMachineState. This is a confirmed regression tracked upstream asshopware/shopware#3717, and it hits any integration that used to get the association for free on older versions. - The genuinely broken case:
stateIdpoints at astate_machine_staterow that no longer resolves correctly, for example a missing translation for the state's name, tracked asshopware/shopware#1690, or a stale or orphaned reference left behind by a migration, a custom import that wrote directly to the database, or a manual SQL edit that bypassed the state machine entirely. - Order data migrated from an older Shopware version or a third-party import tool where the target
state_machine_stateids were regenerated but not every foreign key was remapped along with them. - A plugin or custom entity extension that reads
stateIdfor its own logic and writes it back through a raw update, bypassingStateMachineRegistryand leaving the row technically present but disconnected from the rest of the order's lifecycle.
Both causes look identical from a single response: stateId is a non-null string and stateMachineState is null. Treating every occurrence as a data corruption incident means you waste time investigating and possibly patching orders that were never actually broken. See the citations at the end for the exact issues and docs.
A null stateMachineState is not proof of anything by itself. It only tells you the response you got did not include a resolved state. The only way to know which of the two causes you are looking at is to re-query with the association forced, and if it is still null, check whether the state_machine_state row for that stateId exists at all. Most of the time you will find the row is right there and the first read was simply incomplete, which means the correct action is no write at all.
The fix, as a flow
We never patch stateId and we never assume the worst from one null field. The script re-fetches each flagged delivery or transaction with the association explicitly requested, and only escalates to a foreign key lookup when the association is still empty after that. A genuinely orphaned reference gets relinked through the correct domain transition action, gated by a dry run, and re-verified afterward. Everything that turns out to be a plain association omission is reported and left untouched.
Build it step by step
Get an Admin API access token
Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" // start safe, change to false to write
Authenticate and call the Admin API
A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for every search request and for the transition action call.
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},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_post(path, token, body=None):
r = requests.post(
f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body or {},
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" },
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 apiPost(path, token, body = {}) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Re-fetch with the association explicitly requested
For a delivery or transaction whose plain response had a null stateMachineState, search again with associations.stateMachineState forced. Most records resolve cleanly here, which means the first null was the lazy-load omission, not a data problem.
def search_with_state(token, entity, order_id):
body = {
"page": 1,
"limit": 50,
"filter": [{"type": "equals", "field": "orderId", "value": order_id}],
"associations": {"stateMachineState": {}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/{entity}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
async function searchWithState(token, entity, orderId) {
const body = {
page: 1,
limit: 50,
filter: [{ type: "equals", field: "orderId", value: orderId }],
associations: { stateMachineState: {} },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${entity} search`);
const data = await res.json();
return data.data;
}
Decide, with one pure function
Keep the classification separate from any network call. Given the raw stateId, the (possibly still null) stateMachineState from the forced re-query, and whether a direct lookup of that stateId against state-machine-state found a row, decide whether the record is fine, was simply under-fetched, has a genuinely orphaned foreign key, or never had a stateId at all. Nothing here talks to the network, so it is trivial to test with fixture records.
def classify_state_machine_null(record):
state_id = record.get("stateId")
state_machine_state = record.get("stateMachineState")
resolved_state_row_exists = record.get("resolvedStateRowExists")
if not state_id:
return "MISSING_STATE_ID"
if state_machine_state is None and resolved_state_row_exists is True:
return "ASSOCIATION_OMITTED"
if state_machine_state is None and resolved_state_row_exists is False:
return "ORPHANED_FK"
return "OK"
export function classifyStateMachineNull(record) {
const { stateId, stateMachineState, resolvedStateRowExists } = record;
if (!stateId) return "MISSING_STATE_ID";
if (stateMachineState === null && resolvedStateRowExists === true) return "ASSOCIATION_OMITTED";
if (stateMachineState === null && resolvedStateRowExists === false) return "ORPHANED_FK";
return "OK";
}
Relink an orphaned FK through the transition action, never a PATCH
When the classification is ORPHANED_FK, never write stateId directly. Call the domain transition action instead, such as POST /api/_action/order_transaction/{id}/state/paid or POST /api/_action/order_delivery/{id}/state/ship, so the state machine re-derives a valid stateId through its own guarded transition graph. Re-fetch with associations afterward to confirm stateMachineState.technicalName now resolves.
def transition_route(entity, entity_id, transition):
# entity is "order_transaction" or "order_delivery"
return f"/api/_action/{entity}/{entity_id}/state/{transition}"
def run_transition(token, entity, entity_id, transition):
return api_post(transition_route(entity, entity_id, transition), token, body={})
def state_row_exists(token, state_id):
body = {
"page": 1,
"limit": 1,
"filter": [{"type": "equals", "field": "id", "value": state_id}],
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine-state",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return len(r.json()["data"]) > 0
function transitionRoute(entity, entityId, transition) {
// entity is "order_transaction" or "order_delivery"
return `/api/_action/${entity}/${entityId}/state/${transition}`;
}
async function runTransition(token, entity, entityId, transition) {
return apiPost(transitionRoute(entity, entityId, transition), token, {});
}
async function stateRowExists(token, stateId) {
const body = {
page: 1,
limit: 1,
filter: [{ type: "equals", field: "id", value: stateId }],
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-state`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-state search`);
const data = await res.json();
return data.data.length > 0;
}
Wire it together with a dry run guard
The loop ties every piece together. For each delivery and transaction on an order, search with the association forced, look up the raw state_id when it is still null, run the pure classifier, and only call the transition action for a genuinely ORPHANED_FK when DRY_RUN is off. ASSOCIATION_OMITTED results are reported as false positives and never written to.
Never patch stateId, and never assume a null stateMachineState means broken data on its own. Always re-query with the association forced first. Start with DRY_RUN=true, read the classifications, and only let the script call a transition once you trust the list of genuinely orphaned records.
The full code
Here is the complete script in one file for each language. It authenticates, re-fetches deliveries and transactions with the association forced, cross-checks the raw state_id against state-machine-state when needed, classifies with the pure function, and calls the transition action only for genuinely orphaned references, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Reconcile Shopware 6 order_delivery and order_transaction records where
stateMachineState comes back null, without ever patching stateId directly.
Since Shopware 6.6, stateMachineState on order_delivery and order_transaction is a
lazy-loaded association the Admin API omits by default (shopware/shopware#3717), so a
plain read gives you a valid stateId but a null stateMachineState even though the state
row exists. Separately, a stateId can be genuinely orphaned by a stale migration, a
custom import, or a direct SQL edit that bypassed the state machine.
For each candidate, this re-fetches with associations.stateMachineState forced. If that
resolves, it was just the lazy-load omission and nothing is written. If it is still
null, it looks up the raw stateId against state-machine-state directly. Only a foreign
key that fails to resolve there is treated as ORPHANED_FK, and the repair is the domain
transition action, never a raw write. Run on demand, gated by DRY_RUN. 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("reconcile_state_machine_null")
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"
# entity technical name -> transition to attempt when relinking an orphaned FK
DEFAULT_TRANSITIONS = {
"order_transaction": "paid",
"order_delivery": "ship",
}
def classify_state_machine_null(record):
"""Pure decision. No I/O. Takes a pre-built snapshot and returns a verdict."""
state_id = record.get("stateId")
state_machine_state = record.get("stateMachineState")
resolved_state_row_exists = record.get("resolvedStateRowExists")
if not state_id:
return "MISSING_STATE_ID"
if state_machine_state is None and resolved_state_row_exists is True:
return "ASSOCIATION_OMITTED"
if state_machine_state is None and resolved_state_row_exists is False:
return "ORPHANED_FK"
return "OK"
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},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_post(path, token, body=None):
r = requests.post(
f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body or {},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
def search_with_state(token, entity, order_id):
body = {
"page": 1,
"limit": 50,
"filter": [{"type": "equals", "field": "orderId", "value": order_id}],
"associations": {"stateMachineState": {}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/{entity.replace('_', '-')}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def state_row_exists(token, state_id):
body = {
"page": 1,
"limit": 1,
"filter": [{"type": "equals", "field": "id", "value": state_id}],
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine-state",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return len(r.json()["data"]) > 0
def run_transition(token, entity, entity_id, transition):
return api_post(f"/api/_action/{entity}/{entity_id}/state/{transition}", token, body={})
def reconcile_entity(token, entity, order_id):
"""Re-fetches an entity type for one order and classifies each record.
Returns a list of (record, verdict) pairs.
"""
results = []
for record in search_with_state(token, entity, order_id):
state_machine_state = record.get("stateMachineState")
resolved_state_row_exists = None
if state_machine_state is None and record.get("stateId"):
resolved_state_row_exists = state_row_exists(token, record["stateId"])
snapshot = {
"stateId": record.get("stateId"),
"stateMachineState": state_machine_state,
"resolvedStateRowExists": resolved_state_row_exists,
}
verdict = classify_state_machine_null(snapshot)
results.append((record, verdict))
return results
def run(order_id):
token = get_token()
omitted = 0
orphaned = 0
missing = 0
for entity in ("order_transaction", "order_delivery"):
for record, verdict in reconcile_entity(token, entity, order_id):
record_id = record["id"]
if verdict == "OK":
continue
if verdict == "ASSOCIATION_OMITTED":
log.info("%s %s: association omitted, no fix needed (re-query with associations)", entity, record_id)
omitted += 1
continue
if verdict == "MISSING_STATE_ID":
log.warning("%s %s: stateId itself is missing, needs a full re-transition", entity, record_id)
missing += 1
continue
transition = DEFAULT_TRANSITIONS[entity]
log.warning("%s %s: stateId %s is orphaned. %s '%s'", entity, record_id, record.get("stateId"),
"would run transition" if DRY_RUN else "running transition", transition)
if not DRY_RUN:
run_transition(token, entity, record_id, transition)
refreshed = search_with_state(token, entity, order_id)
match = next((r for r in refreshed if r["id"] == record_id), None)
new_name = ((match or {}).get("stateMachineState") or {}).get("technicalName")
log.info("%s %s now resolves to %s", entity, record_id, new_name)
orphaned += 1
log.info("Done. %d association-omitted (no write), %d orphaned FK %s, %d missing stateId flagged.",
omitted, orphaned, "to relink" if DRY_RUN else "relinked", missing)
if __name__ == "__main__":
order_id = os.environ["ORDER_ID"]
run(order_id)
/**
* Reconcile Shopware 6 order_delivery and order_transaction records where
* stateMachineState comes back null, without ever patching stateId directly.
*
* Since Shopware 6.6, stateMachineState on order_delivery and order_transaction is a
* lazy-loaded association the Admin API omits by default (shopware/shopware#3717), so a
* plain read gives you a valid stateId but a null stateMachineState even though the state
* row exists. Separately, a stateId can be genuinely orphaned by a stale migration, a
* custom import, or a direct SQL edit that bypassed the state machine.
*
* For each candidate, this re-fetches with associations.stateMachineState forced. If that
* resolves, it was just the lazy-load omission and nothing is written. If it is still
* null, it looks up the raw stateId against state-machine-state directly. Only a foreign
* key that fails to resolve there is treated as ORPHANED_FK, and the repair is the domain
* transition action, never a raw write. Run on demand, gated by DRY_RUN. Safe to run
* again and again.
*
* Guide: https://www.allanninal.dev/shopware/order-state-machine-null/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// entity technical name -> transition to attempt when relinking an orphaned FK
const DEFAULT_TRANSITIONS = {
order_transaction: "paid",
order_delivery: "ship",
};
export function classifyStateMachineNull(record) {
const { stateId, stateMachineState, resolvedStateRowExists } = record;
if (!stateId) return "MISSING_STATE_ID";
if (stateMachineState === null && resolvedStateRowExists === true) return "ASSOCIATION_OMITTED";
if (stateMachineState === null && resolvedStateRowExists === false) return "ORPHANED_FK";
return "OK";
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "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 apiPost(path, token, body = {}) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function searchWithState(token, entity, orderId) {
const body = {
page: 1,
limit: 50,
filter: [{ type: "equals", field: "orderId", value: orderId }],
associations: { stateMachineState: {} },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/${entity.replace(/_/g, "-")}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${entity} search`);
const data = await res.json();
return data.data;
}
async function stateRowExists(token, stateId) {
const body = {
page: 1,
limit: 1,
filter: [{ type: "equals", field: "id", value: stateId }],
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-state`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-state search`);
const data = await res.json();
return data.data.length > 0;
}
async function runTransition(token, entity, entityId, transition) {
return apiPost(`/api/_action/${entity}/${entityId}/state/${transition}`, token, {});
}
async function reconcileEntity(token, entity, orderId) {
const results = [];
const records = await searchWithState(token, entity, orderId);
for (const record of records) {
const stateMachineState = record.stateMachineState ?? null;
let resolvedStateRowExists = null;
if (stateMachineState === null && record.stateId) {
resolvedStateRowExists = await stateRowExists(token, record.stateId);
}
const snapshot = {
stateId: record.stateId ?? null,
stateMachineState,
resolvedStateRowExists,
};
const verdict = classifyStateMachineNull(snapshot);
results.push({ record, verdict });
}
return results;
}
export async function run(orderId) {
const token = await getToken();
let omitted = 0;
let orphaned = 0;
let missing = 0;
for (const entity of ["order_transaction", "order_delivery"]) {
const results = await reconcileEntity(token, entity, orderId);
for (const { record, verdict } of results) {
const recordId = record.id;
if (verdict === "OK") continue;
if (verdict === "ASSOCIATION_OMITTED") {
console.log(`${entity} ${recordId}: association omitted, no fix needed (re-query with associations)`);
omitted++;
continue;
}
if (verdict === "MISSING_STATE_ID") {
console.warn(`${entity} ${recordId}: stateId itself is missing, needs a full re-transition`);
missing++;
continue;
}
const transition = DEFAULT_TRANSITIONS[entity];
console.warn(`${entity} ${recordId}: stateId ${record.stateId} is orphaned. ${DRY_RUN ? "would run transition" : "running transition"} '${transition}'`);
if (!DRY_RUN) {
await runTransition(token, entity, recordId, transition);
const refreshed = await searchWithState(token, entity, orderId);
const match = refreshed.find((r) => r.id === recordId);
console.log(`${entity} ${recordId} now resolves to ${match?.stateMachineState?.technicalName}`);
}
orphaned++;
}
}
console.log(`Done. ${omitted} association-omitted (no write), ${orphaned} orphaned FK ${DRY_RUN ? "to relink" : "relinked"}, ${missing} missing stateId flagged.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const orderId = process.env.ORDER_ID;
run(orderId).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classifier is the part most worth testing, because it decides whether the script leaves a record alone, reports it as a false positive, or calls a transition on it. Because classify_state_machine_null is pure and takes a plain snapshot, the test needs no network and no live Shopware store.
from reconcile_state_machine_null import classify_state_machine_null
def record(**over):
base = {
"stateId": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"stateMachineState": {"id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "technicalName": "shipped"},
"resolvedStateRowExists": None,
}
base.update(over)
return base
def test_ok_when_state_resolves():
assert classify_state_machine_null(record()) == "OK"
def test_missing_state_id_when_state_id_is_none():
assert classify_state_machine_null(record(stateId=None, stateMachineState=None)) == "MISSING_STATE_ID"
def test_missing_state_id_when_state_id_is_empty_string():
assert classify_state_machine_null(record(stateId="", stateMachineState=None)) == "MISSING_STATE_ID"
def test_association_omitted_when_row_exists_but_state_null():
result = classify_state_machine_null(
record(stateMachineState=None, resolvedStateRowExists=True)
)
assert result == "ASSOCIATION_OMITTED"
def test_orphaned_fk_when_row_does_not_exist():
result = classify_state_machine_null(
record(stateMachineState=None, resolvedStateRowExists=False)
)
assert result == "ORPHANED_FK"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStateMachineNull } from "./reconcile-state-machine-null.js";
const record = (over = {}) => ({
stateId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
stateMachineState: { id: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", technicalName: "shipped" },
resolvedStateRowExists: null,
...over,
});
test("OK when state resolves", () => {
assert.equal(classifyStateMachineNull(record()), "OK");
});
test("MISSING_STATE_ID when stateId is null", () => {
assert.equal(classifyStateMachineNull(record({ stateId: null, stateMachineState: null })), "MISSING_STATE_ID");
});
test("MISSING_STATE_ID when stateId is an empty string", () => {
assert.equal(classifyStateMachineNull(record({ stateId: "", stateMachineState: null })), "MISSING_STATE_ID");
});
test("ASSOCIATION_OMITTED when row exists but state is null", () => {
const result = classifyStateMachineNull(record({ stateMachineState: null, resolvedStateRowExists: true }));
assert.equal(result, "ASSOCIATION_OMITTED");
});
test("ORPHANED_FK when row does not exist", () => {
const result = classifyStateMachineNull(record({ stateMachineState: null, resolvedStateRowExists: false }));
assert.equal(result, "ORPHANED_FK");
});
Case studies
The fulfillment sync that thought every order was unshipped
A logistics integration read order_delivery.stateMachineState.technicalName to decide which orders needed a shipping label, and after a Shopware upgrade to 6.6 it started treating almost every delivery as unshipped, because stateMachineState came back null across the board. The team's first instinct was to suspect a mass data corruption from the upgrade and began drafting a bulk repair script.
Running the reconciler in dry run first showed every single flagged delivery classify as ASSOCIATION_OMITTED. The integration's search request had simply never asked for the association, which the Admin API used to include for free before 6.6. Adding associations.deliveries.stateMachineState to the original query fixed the integration outright, and zero writes were ever needed.
The handful of transactions a bulk import really did break
A store migrated a batch of historical orders from an older platform using a custom importer that wrote order_transaction rows directly against the database, mapping stateId values from the source system's own state table instead of the target Shopware instance's. Most orders looked fine, but a small subset showed stateMachineState as null even after forcing the association in a re-query.
The reconciler's fallback check against state-machine-state confirmed those specific stateId values did not exist in this store at all, a genuine ORPHANED_FK, unlike the rest that resolved fine. With DRY_RUN=false, the script relinked each one through state/paid, re-verified the technicalName afterward, and left the healthy majority of the batch untouched.
After this runs, a null stateMachineState stops being a mystery. Every flagged record gets a clear verdict: fine as is, simply under-fetched, missing its stateId entirely, or genuinely orphaned. Only the last case ever gets written to, and always through the same transition action the state machine itself would use, so the fix can never leave a delivery or transaction in a state Shopware would not recognize as valid.
FAQ
Why does order_delivery.stateMachineState come back null even though stateId looks valid?
Since Shopware 6.6, stateMachineState on order_delivery and order_transaction is a lazy-loaded association that the Admin API does not include by default. If your search or read request does not explicitly request associations.deliveries.stateMachineState or associations.transactions.stateMachineState, Shopware returns the raw stateId foreign key but leaves the joined stateMachineState object as null, even though the state row exists and is perfectly valid.
How do I tell an omitted association apart from a genuinely broken state reference?
Re-fetch the same delivery or transaction with associations.stateMachineState explicitly requested in the search criteria. If stateMachineState now resolves, the first null was just the lazy-load omission and there is nothing to fix. If it is still null after forcing the association, look up the raw stateId directly against state-machine-state. Zero results back means the foreign key is genuinely orphaned and needs a real repair.
Can I fix a broken stateMachineState by patching stateId directly?
No. Never PATCH stateId on order_delivery or order_transaction. The safe repair is to call the domain transition action, such as POST /api/_action/order_transaction/{id}/state/paid or POST /api/_action/order_delivery/{id}/state/ship, so the state machine re-derives a valid stateId through its own guarded transition graph instead of a raw write that can leave the entity in an inconsistent state again.
Related field notes
Citations
On the problem:
- Shopware v.6.6 stateMachineState for delivery and payment is always = (null) (shopware/shopware #3717). github.com/shopware/shopware/issues/3717
- State machine state - name is expected to be a string but it should be nullable (shopware/shopware #1690). github.com/shopware/shopware/issues/1690
- POST /api/v{version}/_action/order/{orderId}/state/{transition} NOT available (Shopware Community Forum). forum.shopware.com order state transition thread
On the solution:
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
- Shopware Developer Documentation: Search Criteria, Associations and Filters. developer.shopware.com/docs/guides/integrations-api/general-concepts/search-criteria.html
- Shopware Documentation: Orders, Core Checkout Process. docs.shopware.com core checkout process order
Stuck on a tricky one?
If you have a problem in Shopware orders, payments, the state machine, or the message queue 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 up a null state?
If this saved you from patching orders that were never actually broken, 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