Diagnostic Order State Machine
Orders can be force cancelled outside allowed transitions
A support script, an impatient admin user, or a retry loop calls the order cancel endpoint on an order that is already completed, or otherwise nowhere near a legal cancel edge. It should fail. For a long stretch of Shopware 6's life, it did not. A hardcoded escape hatch inside the state machine let the cancel action through no matter what, quietly corrupting the state graph. Here is why that happened, how Shopware fixed it, and a small script that audits your order history for every order this force cancel touched.
StateMachineRegistry::getTransitionDestinationById() historically contained a special case: "Always allow to cancel a payment whether its a valid transition or not." It matched any transition whose action name was cancel and returned the target state without checking whether that edge actually existed for the current state, and it ran for every state machine, not just the payment one it was written for. So POST /api/_action/order/{orderId}/state/cancel on an order sitting in completed, which has no completed to cancelled edge, still succeeded and force-set stateMachineState to cancelled. Shopware deleted that branch in PR #3732, closing issue #3730 (tracked internally as NEXT-23423), so cancel is now validated exactly like any other transition. A small Python or Node.js script can rebuild the legal edge graph, walk state-machine-history, and flag every order that was force cancelled outside a real transition. Full code, tests, and sources are below.
The problem in plain words
Every order in Shopware 6 moves through its order.state state machine along named edges: open to in_progress by process, in_progress to completed by complete, and so on. The whole point of a state machine is that a state can only move along an edge that is actually defined, so StateMachineRegistry is supposed to check the current state's outgoing transitions before letting anything move.
Except for one action name. Deep in getTransitionDestinationById(), a comment reading "Always allow to cancel a payment whether its a valid transition or not" guarded a branch that matched on actionName === 'cancel' and returned the destination state without ever consulting the graph. That check was meant for the payment state machine, where forcing a cancel through made some operational sense, but it was written generically, so it fired for order.state too. Call the cancel transition on an order that is completed, a state with no outgoing cancel edge in the default flow, and it still worked. The order flipped straight to cancelled, no error, no warning, and the state graph was now saying something that could never have happened through a legal path.
Why it happens
The root cause sits in one method, but it surfaces in ordinary operational moments. A few common ways teams ran into it:
- A support agent or admin user clicks cancel on an order that is already
completedor otherwise terminal, expecting Shopware to refuse, and it quietly complies instead. - A custom plugin or automation calls
POST /api/_action/order/{id}/state/cancelas a blunt "just stop this order" tool without first checking the order's currentstateMachineState.technicalNameagainst the legal edges. - A retry loop in an integration fires the cancel transition more than once, and because the check never validated the edge, every retry succeeded rather than failing loudly after the first one.
- A migration or bulk cleanup script assumes cancel is always safe to call, since Shopware's own Admin UI cancel button relies on the same permissive endpoint underneath.
This was a real defect in the platform, not a misunderstanding of the docs. It is tracked publicly as GitHub issue #3730, "every order can be canceled, even if not allowed via statemachine transition," and Shopware closed it by removing the special case entirely in PR #3732. See the citations at the end for the exact issue and fix.
The fix is not to special-case cancel at all, in either direction. Shopware's corrected StateMachineRegistry validates cancel exactly like every other transition: the edge from the current state to the target state, under that specific action name, must exist in the state machine's configured state_machine_transition rows. A detection script should mirror that same rule. Build the real edge graph once, then check every historical cancel against it rather than trusting that toStateMachineState.technicalName === 'cancelled' ever implies the move was legal.
The fix, as a flow
We never try to auto-reverse anything, since there is no guaranteed inverse transition and money, stock, or documents may already have reacted to the bad cancel. Instead the script pulls the real order.state transition graph, walks state-machine-history for every recorded order transition, and flags any row where the action was cancel but that exact fromState to cancelled edge does not exist in the graph. Each flagged row becomes an audit record for manual review.
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, this script only ever reports
// 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, this script only ever reports
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 the state machine graph search, the order search, and the history search.
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) : {};
}
Pull the real order.state transition graph
Search state-machine filtered to technicalName: order.state, with the transitions association nested with fromStateMachineState and toStateMachineState. Fold the response into a map of fromState.technicalName -> {actionName: toState.technicalName}, the same shape the corrected StateMachineRegistry checks against.
def fetch_order_state_edges(token):
body = {
"filter": [{"type": "equals", "field": "technicalName", "value": "order.state"}],
"associations": {
"transitions": {"associations": {"fromStateMachineState": {}, "toStateMachineState": {}}}
},
"limit": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
data = r.json()["data"]
machine = data[0] if data else {}
transitions = ((machine.get("transitions") or {}).get("data")) or machine.get("transitions") or []
if isinstance(transitions, dict):
transitions = transitions.get("data", [])
edges = {}
for t in transitions:
from_name = (t.get("fromStateMachineState") or {}).get("technicalName")
to_name = (t.get("toStateMachineState") or {}).get("technicalName")
action = t.get("actionName")
if not (from_name and to_name and action):
continue
edges.setdefault(from_name, {})[action] = to_name
return edges
async function fetchOrderStateEdges(token) {
const body = {
filter: [{ type: "equals", field: "technicalName", value: "order.state" }],
associations: {
transitions: { associations: { fromStateMachineState: {}, toStateMachineState: {} } },
},
limit: 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine`, {
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 search`);
const payload = await res.json();
const machine = payload.data?.[0] || {};
const raw = machine.transitions;
const transitions = Array.isArray(raw) ? raw : raw?.data || [];
const edges = {};
for (const t of transitions) {
const fromName = t.fromStateMachineState?.technicalName;
const toName = t.toStateMachineState?.technicalName;
const action = t.actionName;
if (!fromName || !toName || !action) continue;
edges[fromName] = edges[fromName] || {};
edges[fromName][action] = toName;
}
return edges;
}
Decide, with one pure function
The rule mirrors the corrected StateMachineRegistry exactly: no special case for actionName === 'cancel'. Look up the edges for the from state, get the candidate target for that action name, and require it to equal the actual target. If the from state is not in the map, or the action does not lead to that target, the transition was not legal.
def is_legal_transition(from_state, to_state, action_name, allowed_edges):
edges = allowed_edges.get(from_state)
if edges is None:
return False
candidate = edges.get(action_name)
return candidate is not None and candidate == to_state
export function isLegalTransition(fromState, toState, actionName, allowedEdges) {
const edges = allowedEdges[fromState];
if (edges === undefined) return false;
const candidate = edges[actionName];
return candidate !== undefined && candidate === toState;
}
Walk state-machine-history and flag illegal cancels
Search state-machine-history filtered to entityName: order, sorted by createdAt, reading entityId, fromStateMachineState.technicalName, toStateMachineState.technicalName, transitionActionName, and createdAt. For each row where the action was cancel, run it through isLegalTransition. A false result means that order was force cancelled outside a real edge.
def fetch_order_cancel_history(token, limit=100):
body = {
"filter": [
{"type": "equals", "field": "entityName", "value": "order"},
{"type": "equals", "field": "transitionActionName", "value": "cancel"},
],
"associations": {"fromStateMachineState": {}, "toStateMachineState": {}},
"sort": [{"field": "createdAt", "order": "ASC"}],
"limit": limit,
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine-history",
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 build_audit_record(history_row):
from_name = (history_row.get("fromStateMachineState") or {}).get("technicalName")
to_name = (history_row.get("toStateMachineState") or {}).get("technicalName")
return {
"historyId": history_row.get("id"),
"orderId": history_row.get("entityId"),
"fromState": from_name,
"toState": to_name,
"transitionActionName": history_row.get("transitionActionName"),
"occurredAt": history_row.get("createdAt"),
}
async function fetchOrderCancelHistory(token, limit = 100) {
const body = {
filter: [
{ type: "equals", field: "entityName", value: "order" },
{ type: "equals", field: "transitionActionName", value: "cancel" },
],
associations: { fromStateMachineState: {}, toStateMachineState: {} },
sort: [{ field: "createdAt", order: "ASC" }],
limit,
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-history`, {
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-history search`);
const data = await res.json();
return data.data;
}
function buildAuditRecord(historyRow) {
return {
historyId: historyRow.id,
orderId: historyRow.entityId,
fromState: historyRow.fromStateMachineState?.technicalName,
toState: historyRow.toStateMachineState?.technicalName,
transitionActionName: historyRow.transitionActionName,
occurredAt: historyRow.createdAt,
};
}
Wire it together as a report-only run
This job never writes anything. It reads the legal edges once, walks every cancel row in history, and prints an audit record for each one that isLegalTransition rejects. DRY_RUN stays on by convention since there is nothing safe to auto-repair, but if you extend this into a preventive guard in front of your own cancel calls, gate the actual transition call behind the same check and return a 409-style report instead of calling the endpoint.
Do not try to reverse a force cancelled order automatically. There is no guaranteed inverse transition, and stock, documents, or payment records may already have reacted. Treat every flagged row as a manual review and finance reconciliation task, and if you build a preventive guard for new cancels, make it reject before ever calling /api/_action/order/{orderId}/state/cancel.
The full code
Here is the complete script in one file for each language. It authenticates, fetches the real order.state edge graph, walks state-machine-history for cancel actions on orders, and prints an audit record for every one that did not have a legal edge, entirely read-only.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Audit Shopware 6 orders that were force cancelled outside a legal
state machine transition.
StateMachineRegistry::getTransitionDestinationById() historically special-cased
actionName === 'cancel' and let it through regardless of whether the edge existed
in the state machine graph (fixed in platform PR #3732, closing issue #3730).
This script rebuilds the real order.state edge graph, walks state-machine-history
for every order cancel, and reports the ones that had no legal edge. Read-only,
report only. 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("audit_force_cancels")
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"
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100"))
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 fetch_order_state_edges(token):
body = {
"filter": [{"type": "equals", "field": "technicalName", "value": "order.state"}],
"associations": {
"transitions": {"associations": {"fromStateMachineState": {}, "toStateMachineState": {}}}
},
"limit": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
data = r.json()["data"]
machine = data[0] if data else {}
transitions = ((machine.get("transitions") or {}).get("data")) or machine.get("transitions") or []
if isinstance(transitions, dict):
transitions = transitions.get("data", [])
edges = {}
for t in transitions:
from_name = (t.get("fromStateMachineState") or {}).get("technicalName")
to_name = (t.get("toStateMachineState") or {}).get("technicalName")
action = t.get("actionName")
if not (from_name and to_name and action):
continue
edges.setdefault(from_name, {})[action] = to_name
return edges
def is_legal_transition(from_state, to_state, action_name, allowed_edges):
edges = allowed_edges.get(from_state)
if edges is None:
return False
candidate = edges.get(action_name)
return candidate is not None and candidate == to_state
def fetch_order_cancel_history(token, limit=100):
body = {
"filter": [
{"type": "equals", "field": "entityName", "value": "order"},
{"type": "equals", "field": "transitionActionName", "value": "cancel"},
],
"associations": {"fromStateMachineState": {}, "toStateMachineState": {}},
"sort": [{"field": "createdAt", "order": "ASC"}],
"limit": limit,
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine-history",
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 build_audit_record(history_row):
from_name = (history_row.get("fromStateMachineState") or {}).get("technicalName")
to_name = (history_row.get("toStateMachineState") or {}).get("technicalName")
return {
"historyId": history_row.get("id"),
"orderId": history_row.get("entityId"),
"fromState": from_name,
"toState": to_name,
"transitionActionName": history_row.get("transitionActionName"),
"occurredAt": history_row.get("createdAt"),
}
def run():
token = get_token()
allowed_edges = fetch_order_state_edges(token)
history = fetch_order_cancel_history(token, HISTORY_LIMIT)
flagged = 0
for row in history:
from_name = (row.get("fromStateMachineState") or {}).get("technicalName")
to_name = (row.get("toStateMachineState") or {}).get("technicalName")
action = row.get("transitionActionName")
if is_legal_transition(from_name, to_name, action, allowed_edges):
continue
record = build_audit_record(row)
log.warning("Illegal force cancel: order %s went %s -> %s via '%s' at %s",
record["orderId"], record["fromState"], record["toState"],
record["transitionActionName"], record["occurredAt"])
flagged += 1
log.info("Done. %d order(s) flagged as force cancelled outside a legal transition.", flagged)
if __name__ == "__main__":
run()
/**
* Audit Shopware 6 orders that were force cancelled outside a legal
* state machine transition.
*
* StateMachineRegistry::getTransitionDestinationById() historically special-cased
* actionName === 'cancel' and let it through regardless of whether the edge existed
* in the state machine graph (fixed in platform PR #3732, closing issue #3730).
* This script rebuilds the real order.state edge graph, walks state-machine-history
* for every order cancel, and reports the ones that had no legal edge. Read-only,
* report only. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/order-force-cancel-illegal-transition/
*/
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";
const HISTORY_LIMIT = Number(process.env.HISTORY_LIMIT || 100);
export function isLegalTransition(fromState, toState, actionName, allowedEdges) {
const edges = allowedEdges[fromState];
if (edges === undefined) return false;
const candidate = edges[actionName];
return candidate !== undefined && candidate === toState;
}
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 fetchOrderStateEdges(token) {
const body = {
filter: [{ type: "equals", field: "technicalName", value: "order.state" }],
associations: {
transitions: { associations: { fromStateMachineState: {}, toStateMachineState: {} } },
},
limit: 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine`, {
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 search`);
const payload = await res.json();
const machine = payload.data?.[0] || {};
const raw = machine.transitions;
const transitions = Array.isArray(raw) ? raw : raw?.data || [];
const edges = {};
for (const t of transitions) {
const fromName = t.fromStateMachineState?.technicalName;
const toName = t.toStateMachineState?.technicalName;
const action = t.actionName;
if (!fromName || !toName || !action) continue;
edges[fromName] = edges[fromName] || {};
edges[fromName][action] = toName;
}
return edges;
}
async function fetchOrderCancelHistory(token, limit = 100) {
const body = {
filter: [
{ type: "equals", field: "entityName", value: "order" },
{ type: "equals", field: "transitionActionName", value: "cancel" },
],
associations: { fromStateMachineState: {}, toStateMachineState: {} },
sort: [{ field: "createdAt", order: "ASC" }],
limit,
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-history`, {
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-history search`);
const data = await res.json();
return data.data;
}
export function buildAuditRecord(historyRow) {
return {
historyId: historyRow.id,
orderId: historyRow.entityId,
fromState: historyRow.fromStateMachineState?.technicalName,
toState: historyRow.toStateMachineState?.technicalName,
transitionActionName: historyRow.transitionActionName,
occurredAt: historyRow.createdAt,
};
}
export async function run() {
const token = await getToken();
const allowedEdges = await fetchOrderStateEdges(token);
const history = await fetchOrderCancelHistory(token, HISTORY_LIMIT);
let flagged = 0;
for (const row of history) {
const fromName = row.fromStateMachineState?.technicalName;
const toName = row.toStateMachineState?.technicalName;
const action = row.transitionActionName;
if (isLegalTransition(fromName, toName, action, allowedEdges)) continue;
const record = buildAuditRecord(row);
console.warn(`Illegal force cancel: order ${record.orderId} went ${record.fromState} -> ${record.toState} via '${record.transitionActionName}' at ${record.occurredAt}`);
flagged++;
}
console.log(`Done. ${flagged} order(s) flagged as force cancelled outside a legal transition.`);
}
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 historical cancel gets flagged as illegal. Because isLegalTransition is pure and takes the edge graph as a plain argument, the test needs no network and no Shopware store. It just feeds in fixture graphs and checks the answer.
from audit_force_cancels import is_legal_transition
ORDER_STATE_EDGES = {
"open": {"process": "in_progress", "cancel": "cancelled"},
"in_progress": {"complete": "completed", "cancel": "cancelled"},
"cancelled": {"reopen": "open"},
"completed": {},
}
def test_legal_cancel_from_open():
assert is_legal_transition("open", "cancelled", "cancel", ORDER_STATE_EDGES) is True
def test_legal_cancel_from_in_progress():
assert is_legal_transition("in_progress", "cancelled", "cancel", ORDER_STATE_EDGES) is True
def test_illegal_cancel_from_completed_has_no_edge():
assert is_legal_transition("completed", "cancelled", "cancel", ORDER_STATE_EDGES) is False
def test_illegal_when_from_state_unknown_to_graph():
assert is_legal_transition("made_up_state", "cancelled", "cancel", ORDER_STATE_EDGES) is False
def test_illegal_when_action_name_does_not_match_edge():
assert is_legal_transition("in_progress", "cancelled", "complete", ORDER_STATE_EDGES) is False
def test_illegal_when_candidate_target_differs_from_actual_target():
assert is_legal_transition("open", "completed", "cancel", ORDER_STATE_EDGES) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isLegalTransition } from "./audit-force-cancels.js";
const ORDER_STATE_EDGES = {
open: { process: "in_progress", cancel: "cancelled" },
in_progress: { complete: "completed", cancel: "cancelled" },
cancelled: { reopen: "open" },
completed: {},
};
test("legal cancel from open", () => {
assert.equal(isLegalTransition("open", "cancelled", "cancel", ORDER_STATE_EDGES), true);
});
test("legal cancel from in_progress", () => {
assert.equal(isLegalTransition("in_progress", "cancelled", "cancel", ORDER_STATE_EDGES), true);
});
test("illegal cancel from completed has no edge", () => {
assert.equal(isLegalTransition("completed", "cancelled", "cancel", ORDER_STATE_EDGES), false);
});
test("illegal when from state unknown to graph", () => {
assert.equal(isLegalTransition("made_up_state", "cancelled", "cancel", ORDER_STATE_EDGES), false);
});
test("illegal when action name does not match edge", () => {
assert.equal(isLegalTransition("in_progress", "cancelled", "complete", ORDER_STATE_EDGES), false);
});
test("illegal when candidate target differs from actual target", () => {
assert.equal(isLegalTransition("open", "completed", "cancel", ORDER_STATE_EDGES), false);
});
Case studies
The internal helper that cancelled completed orders
A furniture retailer built a small internal tool so support agents could cancel problem orders in one click. Nobody had checked whether cancel was actually legal from every state, since Shopware never complained. Months later a finance audit found dozens of completed orders sitting as cancelled, with shipped goods and settled payments that no longer matched the order's own status.
Running the history audit against the real order.state graph identified every affected order in minutes by matching each one to a cancel row with no legal edge. Finance reconciled each one by hand instead of guessing, and the support tool was rewritten to check isLegalTransition before ever calling cancel.
An integration that cancelled the same order twice
A marketplace sync integration retried a cancel call after a timeout, not realizing the first call had already succeeded. The second call landed on an order already cancelled, which should have been rejected outright, but the hardcoded escape hatch let it through as a no-op that still wrote a fresh history row, muddying the audit trail.
Once the platform fix landed, the same retry produced a proper validation failure instead of a phantom second cancel, and the team added the history audit as a weekly job to catch anything from before the upgrade.
After you run this audit, you have a precise list of every order that was force cancelled outside a legal transition, with the exact from state, to state, action name, and timestamp for each one, ready for finance or operations to reconcile by hand. Once you are on a Shopware version with PR #3732, new illegal cancels cannot happen from the platform itself. If you also gate your own cancel calls behind isLegalTransition, nothing in your stack can reintroduce the same corruption going forward.
FAQ
Why could a Shopware 6 order be cancelled even when cancel was not a valid transition?
StateMachineRegistry::getTransitionDestinationById() contained a hardcoded rule to always allow a cancel action, regardless of whether that transition actually existed as an edge from the order's current state in the state machine graph. Calling the cancel transition endpoint on an order in a state like completed, which has no completed to cancelled edge in the default order.state flow, still succeeded and force-set the order to cancelled.
Has Shopware fixed the cancel escape hatch in the state machine?
Yes. Shopware removed the special-cased branch in the platform repository in PR #3732, which closed issue #3730 and was tracked internally as NEXT-23423. After the fix, every transition, including cancel, is validated strictly against the configured state_machine_transition edges for that state machine, so an illegal cancel is rejected instead of silently applied.
Can I safely reverse an order that was force cancelled outside a legal transition?
Not automatically. There is no guaranteed inverse transition back to the order's original state, and stock, documents, or payment records may already have reacted to the cancellation. The safe approach is to detect and report every illegal cancel from state_machine_history for manual review, and to prevent new ones going forward by checking the transition against the legal edge graph before ever calling the cancel endpoint.
Related field notes
Citations
On the problem:
- every order can be canceled, even if not allowed via statemachine transition. GitHub Issue #3730, shopware/shopware. github.com/shopware/shopware/issues/3730
- fix state-machine transition handling. GitHub PR #3732, shopware/shopware. github.com/shopware/shopware/pull/3732
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
On the solution:
- Shopware Admin API Reference (Stoplight): Order Management. shopware.stoplight.io/docs/admin-api/fdd24cc76f22d-order-management
- Shopware Admin API Reference (Stoplight): StateMachine. shopware.stoplight.io/docs/admin-api/4f023ac2306d8-state-machine
- Shopware Admin API Reference (Stoplight): StateMachineHistory. shopware.stoplight.io/docs/admin-api/9a2d77ad40c1e-state-machine-history
Stuck on a tricky one?
If you have a problem in Shopware orders, the state machine, stock, 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 catch a bad cancel?
If this helped you find orders that were force cancelled outside a legal transition, 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