Symfony Workflow: Guards und Transition-Blocking praktisch nutzen
AI generated
SF
{ }
Symfony · Workflow · Domain Logic
Workflow Guards und
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.

16 Min. Lesezeit Workflow Component TransitionBlocker

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.

11. FAQ: Workflow-Guards: Das Wichtigste auf einen Blick

1Was ist der Unterschied zwischen der from/to-Definition und einem Guard?
from/to legt fest, aus welchem Zustand heraus eine Transition strukturell überhaupt erreichbar ist. Ein Guard prüft zusätzlich eine fachliche Bedingung, die unabhängig vom aktuellen Marking-Zustand erfüllt sein muss, etwa ob eine Bestellung bereits versandt wurde.
2Wann sollte ich die deklarative guard-Option statt eines Event-Listeners nutzen?
Für kurze, einzeilige Bedingungen ohne komplexe Serviceabhängigkeiten ist die deklarative guard-Option in der YAML-Konfiguration ausreichend und übersichtlicher. Sobald mehrere Services injiziert oder externe Aufrufe gemacht werden müssen, ist ein dedizierter Event-Listener die klarere Wahl.
3Was macht TransitionBlocker anders als ein einfaches setBlocked(true)?
TransitionBlocker transportiert zusätzlich zur blockierenden Wirkung eine menschenlesbare Nachricht und einen stabilen, maschinenlesbaren Code, über den Frontend oder API gezielt unterschiedliche Fehlermeldungen oder Handlungsoptionen anzeigen können, statt nur ein pauschales Blockiert-Flag zu erhalten.
4Kann ein Guard-Listener mehrere Gründe gleichzeitig melden?
Ja, ein Listener kann mehrere TransitionBlocker-Instanzen über mehrere addTransitionBlocker()-Aufrufe registrieren, wenn mehrere Bedingungen gleichzeitig verletzt sind. buildTransitionBlockerList() liefert anschließend alle registrierten Gründe gesammelt zurück.
5Sollte Berechtigungslogik im Guard-Listener oder im Security Voter liegen?
Berechtigungslogik gehört in einen Security Voter, weil er wiederverwendbar in Controllern und Templates ist und unabhängig vom Workflow getestet werden kann. Der Guard referenziert den Voter dann lediglich über is_granted(), ohne die Logik zu duplizieren.
6Was passiert, wenn ich apply() aufrufe, obwohl die Transition blockiert ist?
apply() wirft eine LogicException, sobald mindestens ein TransitionBlocker registriert ist. In der Praxis sollte deshalb vor jedem apply()-Aufruf explizit can() oder buildTransitionBlockerList() geprüft werden, um die konkreten Blockierungsgründe dem Nutzer anzeigen zu können.
7Werden alle Guard-Listener ausgeführt, auch wenn einer bereits blockiert hat?
Ja, alle registrierten Listener für eine Transition werden grundsätzlich ausgeführt, unabhängig davon, ob ein vorheriger Listener bereits einen TransitionBlocker hinzugefügt hat. Wer teure Prüfungen überspringen will, muss das explizit im eigenen Listener implementieren.
8Kann ich Guards für alle Transitionen eines Workflows gleichzeitig definieren?
Ja, über das generische Event workflow.guard lauscht ein Listener auf sämtliche Guard-Events aller konfigurierten Workflows gleichzeitig, während workflow.[name].guard.[transition] nur für eine spezifische Transition eines bestimmten Workflows ausgelöst wird.
9Wie teste ich einen Guard-Listener ohne vollständigen Kernel-Boot?
Ein GuardEvent lässt sich manuell mit einem Test-Subjekt, einer Transition und einer Marking-Instanz konstruieren und direkt an die __invoke()-Methode des Listeners übergeben. Anschließend wird lediglich geprüft, ob und mit welchem Code ein TransitionBlocker hinzugefügt wurde.
10Lohnt sich der zusätzliche Aufwand mit Guards für kleine Workflows?
Bei sehr einfachen Workflows mit nur zwei oder drei Zuständen ohne zusätzliche fachliche Bedingungen reicht oft die reine from/to-Definition aus. Sobald jedoch Berechtigungen oder zeitabhängige Bedingungen wie ein Versandstatus ins Spiel kommen, zahlt sich die klare Trennung durch Guards schnell aus.