← Alle Beiträge

Shopware 6: „Illegal transition … Possible transitions are: reopen“ verstehen und abstellen

Shopware 6: „Illegal transition … Possible transitions are: reopen“ verstehen und abstellen

Die Meldung taucht selten allein auf. Wenn IllegalTransitionException mit dem Zusatz „Possible transitions are: reopen“ im Log steht, dann oft zehntausendfach und gehäuft in einem Shop. Das Muster passt zu einer Schnittstelle, die dieselben Statusänderungen immer wieder schickt, weil sie jedes Mal mit einem 400 abgewiesen wird. Mit dem Checkout hat das wenig zu tun.

Die Fehlermeldung

request.CRITICAL: Uncaught PHP Exception
Shopware\Core\System\StateMachine\Exception\IllegalTransitionException:
"Illegal transition "ship" from state "018f2c3d4e5f6a7b8c9d0e1f2a3b4c5d".
Possible transitions are: reopen" at StateMachineException.php line 29

Dieselbe Meldung gibt es mit "complete", "cancel" und "process" statt "ship". Der Teil nach „Possible transitions are“ ist bei allen gleich: nur reopen.

Was dahintersteckt

Bestellung, Lieferung und Zahlung haben in Shopware jeweils eine eigene State Machine (order.state, order_delivery.state, order_transaction.state). Eine Statusänderung läuft immer über eine benannte Aktion wie ship oder complete, und die ist nur von bestimmten Ausgangszuständen aus erlaubt. Gibt es für den aktuellen Zustand keinen Übergang mit diesem Namen, wirft der StateMachineRegistry die IllegalTransitionException. Die Antwort an den Aufrufer ist ein HTTP 400 mit dem Code SYSTEM__ILLEGAL_STATE_TRANSITION.

Interessant ist die Liste am Ende. Shopware schreibt dort alle Aktionen hinein, die vom aktuellen Zustand aus möglich wären. Steht da nur reopen, sitzt das Objekt in einem Endzustand. In einer Standardinstallation sind das:

  • Bestellung completed oder cancelled
  • Lieferung cancelled oder returned

Von dort führt nur der Weg zurück auf „offen“. Wer eine stornierte Bestellung abschließen will, muss sie erst wieder öffnen, dann in Bearbeitung setzen und dann abschließen.

Ein Detail hilft beim Eingrenzen. Wenn ein Objekt schon im Zielzustand steht, wirft Shopware keinen Fehler, sondern überspringt den Übergang still (intern eine UnnecessaryTransitionException, die abgefangen wird). Ein cancel auf eine bereits stornierte Bestellung fällt also nie auf. Daraus lässt sich ablesen, was wirklich passiert ist:

Aktion im Log Wahrscheinlicher Zustand
cancel Bestellung ist abgeschlossen
complete Bestellung ist storniert
process Bestellung ist abgeschlossen oder storniert
ship Lieferung ist storniert oder retourniert

Das gilt für die Standard-State-Machines. Hat ein Plugin eigene Zustände oder Übergänge angelegt, kann es abweichen.

Und noch eine Falle: Die ID in from state "…" ist nicht die ID der Bestellung, sondern die des Zustands, also eine Zeile in state_machine_state. Wer im Admin nach dieser ID sucht, findet nichts.

Prüfen, ob es wirklich das ist

Zuerst den Zustand auflösen:

SELECT sm.technical_name AS state_machine, s.technical_name AS state
FROM state_machine_state s
JOIN state_machine sm ON sm.id = s.state_machine_id
WHERE s.id = UNHEX('018f2c3d4e5f6a7b8c9d0e1f2a3b4c5d');

Kommt order.state / cancelled zurück, ist die Sache klar. Als Nächstes willst du wissen, wer die Anfrage schickt. Der Log-Kanal request zeigt, dass sie über HTTP kam, und Statuswechsel von außen laufen über die Admin-API:

POST /api/_action/order/{orderId}/state/{transition}
POST /api/_action/order_delivery/{orderDeliveryId}/state/{transition}
POST /api/_action/order_transaction/{orderTransactionId}/state/{transition}

Im Access-Log des Webservers suchst du nach diesen Pfaden mit Status 400:

grep -E '_action/order(_delivery|_transaction)?/[0-9a-f]{32}/state/' /var/log/nginx/access.log \
  | grep '" 400 ' | awk '{print $1, $7}' | sort | uniq -c | sort -rn | head

Tauchen dieselben IDs dutzendfach auf, hast du die Ursache für das Volumen gefunden: ein ERP, eine Warenwirtschaft oder ein Fulfillment-Dienst, der seine Statusmeldung bei jedem Lauf erneut schickt. Die IP oder der User-Agent sagen dir meist, welcher. Unter Einstellungen → System → Integrationen siehst du, welche Zugänge überhaupt existieren.

Welche Aktionen für eine konkrete Bestellung gerade erlaubt sind, liefert Shopware dir direkt:

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://example-shop.de/api/_action/state-machine/order/$ORDER_ID/state"

Für Lieferungen setzt du order_delivery und die ID der Lieferung ein. Die Antwort enthält für jeden möglichen Übergang actionName, fromStateName und toStateName.

Die Lösung

Shopware tut hier genau, was es soll. Repariert wird auf der Seite des Aufrufers, und dafür gibt es zwei Teile.

Der erste ist eine fachliche Entscheidung: Wer ist die Wahrheit für den Bestellstatus? Wenn eine Bestellung im Shop storniert wurde, die Warenwirtschaft sie aber versendet, muss sich jemand diesen Fall ansehen. Die API hat dann nur ein Abgleichsproblem sichtbar gemacht. Pauschal reopen davorzuschalten, damit der Fehler verschwindet, macht aus einer Stornierung stillschweigend wieder eine offene Bestellung. Inklusive der Statusmails, die an diesen Übergängen hängen können. Das willst du nur, wenn es wirklich so gewollt ist.

Der zweite Teil ist technisch und gilt immer: Die Schnittstelle muss den aktuellen Zustand kennen, bevor sie schreibt, und sie darf einen 400 mit SYSTEM__ILLEGAL_STATE_TRANSITION nicht wiederholen. Der Fehler ist dauerhaft. Beim zehnten Versuch ist die Bestellung genauso storniert wie beim ersten.

In der Praxis heißt das:

  1. Vor dem Übergang den Zustand lesen, entweder über den Endpoint oben oder per Search-Request mit associations auf stateMachineState.
  2. Ist das Ziel schon erreicht oder der Zustand final (completed, cancelled, returned), den Übergang auslassen und den Fall protokollieren.
  3. Nur erlaubte Aktionsnamen verwenden. ship gibt es nur auf order_delivery, nicht auf order. Auf der Bestellung führt der Weg nach „abgeschlossen“ über process und dann complete.
  4. Antworten mit SYSTEM__ILLEGAL_STATE_TRANSITION aus der Retry-Logik nehmen und in eine Fehlerliste für Menschen schreiben.

Wenn das Plugin oder der Connector nicht von dir stammt, ist Punkt 4 die Frage, die du dem Hersteller stellst. Bis dahin bleibt dir nur, die betroffenen Bestellungen im Shop von Hand nachzuziehen oder in der Schnittstelle vom Sync auszunehmen. Das ist ein Workaround, kein Fix.

Wenn das nicht hilft

Nicht jede IllegalTransitionException kommt von außen. Zwei weitere Quellen kommen in Frage.

Der Flow Builder kann Status setzen. Eine Flow-Aktion „Status ändern“, die auf ein Ereignis reagiert, das auch bei abgeschlossenen Bestellungen feuert, läuft in denselben Fehler. Dann steht kein API-Pfad im Access-Log, und der Fehler erscheint im Zusammenhang mit dem auslösenden Request oder einem Worker-Prozess.

Zahlungsplugins setzen den Transaktionsstatus über Webhooks. Bei order_transaction.state sieht die Liste der möglichen Übergänge aber anders aus. Eine stornierte Zahlung bietet in der Standardkonfiguration neben reopen auch refund und refund_partially an. Steht nur reopen da, geht es also um Bestellung oder Lieferung, nicht um die Zahlung.

Ist die Liste nach „Possible transitions are:“ ganz leer, hat das Objekt einen Zustand ohne ausgehende Übergänge. Dann lohnt ein Blick in state_machine_transition, ob ein Plugin die State Machine verändert hat.

Die Übergänge stehen im Shopware-Quellcode in Migration1536233560BasicData und den späteren Migrationen Migration1573049297AddReopenTransitionToDeliveryStates und Migration1600349343AddDeliveryStateTransitions. Das Verhalten beim bereits erreichten Zielzustand findest du in StateMachineRegistry::getTransitionDestinationById().

37.000 abgewiesene Statusänderungen sind kein Ausfall, den ein Kunde sieht. Sie bedeuten aber, dass Shop und Warenwirtschaft bei einem Teil der Bestellungen nicht mehr dasselbe glauben, und das merkt sonst erst die Buchhaltung. ShopSignal zählt Fehler und Warnungen pro Shop, macht die Logs per Volltextsuche durchsuchbar und meldet sich über Alarmregeln, wenn eine Fehlerart plötzlich hochschießt.