Transition-Blocking praktisch nutzen
Die from/to-Definition einer Transition legt nur fest, aus welchem Zustand heraus ein Übergang überhaupt erreichbar ist, sagt aber nichts darüber aus, ob er unter den aktuellen fachlichen Bedingungen erlaubt sein soll. Genau dafür bietet die Symfony Workflow-Component guard-Events und die TransitionBlocker-Klasse, mit denen sich Zustandsübergänge feingranular blockieren und mit sprechenden Fehlermeldungen versehen lassen, kombinierbar mit Security Voters für rollenbasierte Regeln.
Inhaltsverzeichnis
- 1. Warum from/to allein nicht ausreicht
- 2. Wie guard-Events für bedingte Zustandsübergänge funktionieren
- 3. TransitionBlocker mit sprechenden Fehlermeldungen
- 4. can(), buildTransitionBlockerList() und apply() im Zusammenspiel
- 5. Kombination mit Security Voters für rollenbasierte Übergänge
- 6. Praxisbeispiel: Bestellung nur stornierbar, solange sie nicht versandt ist
- 7. Mehrere Guard-Listener und ihre Ausführungsreihenfolge
- 8. Fehlermeldungen in UI und API korrekt anzeigen
- 9. Testing von Guards und Fazit
- 10. Zusammenfassung
- 11. FAQ
1. Warum from/to allein nicht ausreicht
Eine Workflow- oder State-Machine-Definition in Symfony beschreibt zunächst nur die Topologie möglicher Zustandsübergänge: eine Transition mit from: [pending] und to: shipped legt fest, dass der Übergang shipping ausschließlich aus dem Zustand pending heraus erreichbar ist. Diese strukturelle Regel beantwortet aber nicht die eigentlich interessante fachliche Frage, ob der Übergang im konkreten Einzelfall auch erlaubt sein soll. Eine Bestellung im Zustand pending mag zwar strukturell stornierbar sein, fachlich aber nicht mehr, wenn das Lager die Ware bereits kommissioniert hat, obwohl der Marking-Zustand selbst das noch nicht abbildet.
Genau für diese Lücke zwischen struktureller Erreichbarkeit und fachlicher Zulässigkeit bietet die Workflow-Component einen eigenen Mechanismus: guard-Events, die vor jedem Aufruf von can() oder apply() ausgelöst werden und es erlauben, eine Transition zusätzlich zu blockieren, unabhängig davon, was die from/to-Definition an sich zulassen würde. Damit bleibt die Workflow-Konfiguration selbst schlank und rein strukturell, während die fachliche Zulässigkeitsprüfung sauber getrennt in Guard-Listenern oder deklarativen Guard-Ausdrücken lebt, was die Testbarkeit und Wartbarkeit beider Ebenen deutlich verbessert.
2. Wie guard-Events für bedingte Zustandsübergänge funktionieren
Für jede Transition dispatcht die Workflow-Component beim Aufruf von can() oder apply() ein Event mit dem Namen workflow.[workflow_name].guard.[transition_name], zusätzlich zum generischeren workflow.guard, das für alle Workflows gleichzeitig lauscht. Ein an dieses Event angehängter Listener erhält ein GuardEvent, über das er sowohl das betroffene Subjekt als auch die aktuelle Transition auslesen und bei Bedarf blockieren kann, entweder über setBlocked(true, 'Grund') oder präziser über addTransitionBlocker() mit einer eigenen TransitionBlocker-Instanz. Für einfache, rein deklarative Bedingungen bietet sich alternativ die guard-Option direkt in der Workflow-Konfiguration an, die einen ExpressionLanguage-Ausdruck auswertet und dabei Zugriff auf subject und, sofern der Security-Voter-Kontext geladen ist, auf is_granted() hat.
Der entscheidende Unterschied zwischen der deklarativen guard-Option und einem dedizierten Event-Listener liegt im Umfang der möglichen Logik: Die guard-Option eignet sich für kurze, einzeilige Bedingungen direkt in der YAML-Konfiguration, während komplexere Prüfungen, etwa mit mehreren Serviceabhängigkeiten oder externen API-Aufrufen, in einem eigenen PHP-Listener klarer und besser testbar untergebracht sind. Beide Mechanismen greifen ineinander: Auch bei Verwendung der guard-Option wird intern dasselbe GuardEvent verwendet, sodass sich beide Ansätze im selben Workflow problemlos kombinieren lassen.
# config/packages/workflow.yaml
framework:
workflows:
order:
type: state_machine
marking_store:
type: method
property: status
supports:
- App\Entity\Order
initial_marking: pending
places: [pending, shipped, cancelled, delivered]
transitions:
cancel:
from: pending
to: cancelled
# Deklarativer Guard direkt in der Konfiguration
guard: "is_granted('ORDER_CANCEL', subject)"
ship:
from: pending
to: shipped
3. TransitionBlocker mit sprechenden Fehlermeldungen
Die Klasse Symfony\Component\Workflow\TransitionBlocker nimmt im Konstruktor eine menschenlesbare Nachricht sowie einen maschinenlesbaren Code entgegen, über den sich der Grund einer Blockade im Frontend oder in der API präzise unterscheiden lässt, statt nur ein pauschales 'nicht erlaubt' auszugeben. Ein Guard-Listener kann pro Aufruf mehrere TransitionBlocker gleichzeitig registrieren, wenn mehrere Bedingungen gleichzeitig verletzt sind, sodass buildTransitionBlockerList() später alle Gründe auf einmal zurückliefern kann, statt nur den ersten gefundenen Fehler zu melden.
In der Praxis empfiehlt es sich, für jeden Blockierungsgrund einen stabilen, sprechenden Code zu vergeben, etwa order_already_shipped oder insufficient_permissions, damit das Frontend gezielt unterschiedliche Fehlermeldungen oder Handlungsoptionen anzeigen kann, statt die Nachricht als reinen Freitext parsen zu müssen. Diese Codes bleiben auch bei einer späteren Übersetzung der Nachricht stabil, was sie zu einer robusteren Schnittstelle zwischen Backend-Logik und Frontend-Darstellung macht als der reine Nachrichtentext.
<?php
// src/EventListener/OrderCancelGuardListener.php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Order;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
#[AsEventListener(event: 'workflow.order.guard.cancel')]
final class OrderCancelGuardListener
{
public function __invoke(GuardEvent $event): void
{
/** @var Order $order */
$order = $event->getSubject();
if ($order->getShippedAt() !== null) {
$event->addTransitionBlocker(new TransitionBlocker(
sprintf('Bestellung wurde bereits am %s versandt.', $order->getShippedAt()->format('d.m.Y')),
'order_already_shipped',
));
}
}
}
4. can(), buildTransitionBlockerList() und apply() im Zusammenspiel
Die Methode can() des WorkflowInterface liefert lediglich ein Boolean zurück und eignet sich damit gut für einfache Sichtbarkeitsentscheidungen, etwa ob ein Stornieren-Button überhaupt angezeigt wird. Sobald aber der konkrete Grund einer Blockade für eine Fehlermeldung benötigt wird, ist buildTransitionBlockerList() das passendere Werkzeug, weil es eine TransitionBlockerList mit allen registrierten TransitionBlocker-Instanzen zurückgibt, über die sich iterieren und deren Nachrichten sowie Codes direkt auslesen lassen. apply() selbst wirft eine LogicException, wenn die Transition blockiert ist, weshalb es sich in der Praxis bewährt, vor jedem apply()-Aufruf explizit can() oder buildTransitionBlockerList() zu prüfen, statt sich auf das Abfangen der Exception zu verlassen.
Ein häufiger Fehler ist, apply() direkt aufzurufen und lediglich einen generischen try/catch um die LogicException zu legen, wodurch alle spezifischen Blockierungsgründe verloren gehen und dem Nutzer nur eine unspezifische Fehlermeldung angezeigt werden kann. Der sauberere Weg ist, vor dem eigentlichen apply()-Aufruf buildTransitionBlockerList() auszuwerten, bei nicht-leerer Liste die enthaltenen TransitionBlocker in eine strukturierte API-Antwort oder Flash-Message umzuwandeln und apply() erst dann aufzurufen, wenn die Liste leer ist.
5. Kombination mit Security Voters für rollenbasierte Übergänge
Rollenbasierte Zugriffsregeln gehören fachlich eher in einen Security Voter als direkt in einen Workflow-Guard-Listener, weil Voter bereits für genau diesen Zweck existieren und sich an anderen Stellen der Anwendung wiederverwenden lassen, etwa in Controllern oder Twig-Templates über is_granted(). Die deklarative guard-Option kann is_granted() direkt referenzieren, sodass ein Voter mit dem Attribut ORDER_CANCEL sowohl im Workflow-Guard als auch im Controller identisch angewendet wird, ohne die Rechteregel doppelt zu implementieren.
Ein Voter für diesen Zweck prüft typischerweise nicht nur die Rolle des aktuellen Nutzers, sondern auch eine Beziehung zwischen Nutzer und Subjekt, etwa ob der Nutzer Eigentümer der Bestellung ist oder eine Support-Rolle mit erweiterten Rechten besitzt. Dadurch bleibt die Guard-Konfiguration im Workflow selbst deklarativ und kurz, während die eigentliche, potenziell komplexe Autorisierungslogik zentral im Voter liegt und dort unabhängig vom Workflow getestet werden kann.
<?php
// src/Security/Voter/OrderCancelVoter.php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Order;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class OrderCancelVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'ORDER_CANCEL' && $subject instanceof Order;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Order $order */
$order = $subject;
return $order->getCustomer() === $user || $user->hasRole('ROLE_SUPPORT');
}
}
6. Praxisbeispiel: Bestellung nur stornierbar, solange sie nicht versandt ist
Ein klassisches Beispiel für die Kombination aus struktureller Transition und fachlichem Guard ist der Stornoprozess einer Bestellung: Strukturell erlaubt die Workflow-Definition den Übergang cancel aus dem Zustand pending heraus, weil eine bereits versandte oder gelieferte Bestellung ohnehin nie diesen Zustand erreicht. Fachlich reicht das aber nicht aus, weil zwischen dem Setzen des Zustands pending und dem tatsächlichen Versand ein zeitliches Fenster liegt, in dem der Lagerprozess die Ware bereits kommissioniert und verpackt haben kann, ohne dass der Marking-Zustand der Bestellung sich dadurch schon geändert hat.
Der Guard-Listener aus dem vorherigen Abschnitt schließt genau diese Lücke, indem er unabhängig vom Marking-Zustand ein zusätzliches Feld shippedAt auf der Order-Entity prüft, das vom Lagerprozess separat gesetzt wird, sobald das Paket physisch das Lager verlässt. Diese Trennung zwischen Workflow-Zustand und einem zusätzlichen, fachlich getriebenen Zeitstempel ist ein gängiges Muster, weil sich nicht jede relevante fachliche Bedingung sinnvoll als eigener Workflow-Zustand modellieren lässt, ohne die State-Machine unnötig aufzublähen.
7. Mehrere Guard-Listener und ihre Ausführungsreihenfolge
Für eine einzelne Transition können mehrere Guard-Listener gleichzeitig registriert sein, etwa einer für die Berechtigungsprüfung über den Security Voter und ein weiterer für die reine Geschäftslogik wie den Versandstatus. Alle registrierten Listener werden bei jedem can()- oder apply()-Aufruf ausgeführt, unabhängig davon, ob ein früherer Listener die Transition bereits blockiert hat, sodass am Ende buildTransitionBlockerList() sämtliche Blockierungsgründe gesammelt zurückgibt, nicht nur den ersten gefundenen.
Über die Standard-EventDispatcher-Priorität lässt sich die Reihenfolge steuern, in der Listener ausgewertet werden, was relevant wird, wenn ein teurer Listener, etwa einer mit externem API-Aufruf, nur dann ausgeführt werden soll, wenn eine günstigere Vorprüfung nicht bereits blockiert hat. Da alle Listener grundsätzlich weiterlaufen, muss eine solche Optimierung explizit im Listener selbst implementiert werden, etwa indem der teure Listener zuerst prüft, ob das GuardEvent bereits blockiert ist, und in diesem Fall die eigene, teure Prüfung überspringt.
8. Fehlermeldungen in UI und API korrekt anzeigen
In einer klassischen serverseitig gerenderten Symfony-Anwendung liest der Controller vor dem apply()-Aufruf buildTransitionBlockerList() aus, wandelt jede enthaltene TransitionBlocker-Nachricht in eine Flash-Message um und leitet den Nutzer zurück auf die Detailseite, wo die Nachrichten sichtbar werden. Wichtig ist, dabei den mitgelieferten Code der TransitionBlocker-Instanz zu nutzen, um bei Bedarf ein passendes Icon oder eine Handlungsempfehlung anzuzeigen, etwa einen Link zum Support-Kontaktformular bei einer Berechtigungsblockade statt nur bei einer reinen Statusblockade.
In einer API-gesteuerten Anwendung, etwa mit API Platform, lassen sich die TransitionBlocker-Codes direkt als strukturiertes Fehlerformat mit HTTP-Status 422 zurückgeben, wobei jeder Blockierungsgrund als eigenes Objekt mit code und detail im JSON-Body erscheint. Das Frontend kann diese Codes dann unabhängig von der konkreten, möglicherweise übersetzten Nachricht auswerten und gezielt reagieren, was besonders in mehrsprachigen Anwendungen wichtig ist, weil sich der Nachrichtentext je nach Locale ändert, der Code aber stabil bleibt.
9. Testing von Guards und Fazit
Guard-Listener lassen sich isoliert und ohne vollständigen Kernel-Boot testen, indem ein GuardEvent manuell mit einem Test-Subjekt und einer Test-Transition instanziiert und direkt an den Listener übergeben wird, wonach lediglich geprüft wird, ob und mit welchem Code ein TransitionBlocker hinzugefügt wurde. Für die Kombination aus Workflow und Guards insgesamt empfiehlt sich zusätzlich ein funktionaler Test, der den echten Workflow-Service aus dem Container holt und für verschiedene Subjekt-Zustände can() sowie buildTransitionBlockerList() aufruft, um sicherzustellen, dass Konfiguration und Listener korrekt zusammenspielen.
Zusammengefasst trennt die Kombination aus from/to-Topologie, guard-Events beziehungsweise TransitionBlocker und Security Votern drei fachlich unterschiedliche Fragen sauber voneinander: welche Übergänge strukturell existieren, welche fachlichen Bedingungen zusätzlich erfüllt sein müssen und wer den Übergang überhaupt auslösen darf. Diese Trennung macht Workflows in Symfony deutlich wartbarer als eine einzelne große if-Kaskade in einem Service, weil jede der drei Fragen unabhängig getestet, geändert und im Frontend unterschiedlich dargestellt werden kann.
| Mechanismus | Zweck | Wo definiert | Testbarkeit |
|---|---|---|---|
| from/to in der Transition | Strukturelle Erreichbarkeit eines Zustands | workflow.yaml | Über can() pro Zustand prüfbar |
| Deklarativer guard-Ausdruck | Kurze, einzeilige fachliche Bedingung | workflow.yaml (ExpressionLanguage) | Über can() mit Test-Subjekten prüfbar |
| Guard-Event-Listener | Komplexe Bedingungen mit Serviceabhängigkeiten | EventListener-Klasse | Isoliert mit manuell erzeugtem GuardEvent testbar |
| Security Voter im Guard | Rollenbasierte, wiederverwendbare Autorisierung | Voter-Klasse | Unabhängig vom Workflow über isGranted() testbar |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Workflow-Guards: Das Wichtigste auf einen Blick
Kernproblem
from/to definiert nur strukturelle Erreichbarkeit, nicht die fachliche Zulässigkeit eines Übergangs.
Lösung
guard-Events und TransitionBlocker blockieren Transitionen zusätzlich und liefern sprechende Fehlergründe.
Rollenbasierte Regeln
Security Voter übernehmen Berechtigungsprüfungen, referenziert über is_granted() im Guard.
Praxis-Empfehlung
Vor apply() immer buildTransitionBlockerList() prüfen statt LogicException abzufangen.