Symfony Workflow: Using Guards and Transition Blocking in Practice
AI generated
SF
{ }
Symfony · Workflow · Domain Logic
Workflow Guards and
Transition Blocking in Practice

The from/to definition of a transition only decides which state a transition is reachable from at all, but says nothing about whether it should be allowed under the current business conditions. That is exactly what the Symfony Workflow component's guard events and TransitionBlocker class are for, letting you block state transitions with fine granularity and meaningful error messages, combinable with Security Voters for role-based rules.

16 min read Workflow Component TransitionBlocker

1. Why from/to alone is not enough

A workflow or state machine definition in Symfony first describes only the topology of possible state transitions: a transition with from: [pending] and to: shipped states that the shipping transition is only reachable from the pending state. This structural rule, however, does not answer the actually interesting business question of whether the transition should be allowed in a specific case. An order in the pending state may be structurally cancellable, but not from a business point of view once the warehouse has already picked the goods, even though the marking state itself does not yet reflect that.

The Workflow component offers a dedicated mechanism for exactly this gap between structural reachability and business-level permissibility: guard events, which fire before every call to can() or apply() and let you additionally block a transition regardless of what the from/to definition alone would permit. That keeps the workflow configuration itself lean and purely structural, while the business-permissibility check lives cleanly separated in guard listeners or declarative guard expressions, which noticeably improves testability and maintainability on both levels.

2. How guard events power conditional state transitions

For every transition, the Workflow component dispatches an event named workflow.[workflow_name].guard.[transition_name] whenever can() or apply() is called, in addition to the more generic workflow.guard, which listens across all workflows at once. A listener attached to this event receives a GuardEvent, through which it can read both the affected subject and the current transition and, if needed, block it, either via setBlocked(true, 'reason') or, more precisely, via addTransitionBlocker() with a custom TransitionBlocker instance. For simple, purely declarative conditions, the guard option directly in the workflow configuration is an alternative, evaluating an ExpressionLanguage expression that has access to subject and, if the security voter context is loaded, to is_granted().

The key difference between the declarative guard option and a dedicated event listener lies in the scope of logic each can hold: the guard option suits short, one-line conditions directly in the YAML configuration, while more complex checks, for example involving several service dependencies or external API calls, are clearer and more testable in a dedicated PHP listener. Both mechanisms work together: even when using the guard option, the same GuardEvent is used internally, so both approaches can be combined without issue within the same workflow.


# 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
                    # Declarative guard directly in the configuration
                    guard: "is_granted('ORDER_CANCEL', subject)"
                ship:
                    from: pending
                    to: shipped

3. TransitionBlocker with meaningful error messages

The class Symfony\Component\Workflow\TransitionBlocker takes a human-readable message and a machine-readable code in its constructor, letting the reason for a block be distinguished precisely in the frontend or API instead of just returning a blanket 'not allowed'. A guard listener can register several TransitionBlocker instances at once per call when several conditions are violated simultaneously, so buildTransitionBlockerList() can later return all reasons at once instead of only reporting the first error found.

In practice it pays off to assign a stable, meaningful code to every blocking reason, such as order_already_shipped or insufficient_permissions, so the frontend can display specific error messages or actions instead of having to parse the message as plain text. These codes also stay stable if the message text is later translated, which makes them a more robust interface between backend logic and frontend display than the message text alone.


<?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('Order was already shipped on %s.', $order->getShippedAt()->format('Y-m-d')),
                'order_already_shipped',
            ));
        }
    }
}

4. How can(), buildTransitionBlockerList() and apply() work together

The can() method of WorkflowInterface only returns a boolean, which makes it a good fit for simple visibility decisions, such as whether a cancel button is shown at all. Once the concrete reason for a block is needed for an error message, buildTransitionBlockerList() is the more appropriate tool, because it returns a TransitionBlockerList containing every registered TransitionBlocker instance, which can be iterated to read out messages and codes directly. apply() itself throws a LogicException when the transition is blocked, so in practice it pays off to explicitly check can() or buildTransitionBlockerList() before every apply() call instead of relying on catching the exception.

A common mistake is calling apply() directly and simply wrapping a generic try/catch around the LogicException, which loses every specific blocking reason and leaves only an unspecific error message to show the user. The cleaner approach is to evaluate buildTransitionBlockerList() before the actual apply() call, turn the contained TransitionBlocker instances into a structured API response or flash message when the list is not empty, and only call apply() once the list is empty.

5. Combining guards with Security Voters for role-based transitions

Role-based access rules belong in a Security Voter rather than directly in a workflow guard listener, because voters already exist for exactly this purpose and can be reused elsewhere in the application, for example in controllers or Twig templates via is_granted(). The declarative guard option can reference is_granted() directly, so a voter with the attribute ORDER_CANCEL is applied identically in the workflow guard and in the controller, without duplicating the permission rule.

A voter for this purpose typically checks not only the current user's role but also a relationship between user and subject, for example whether the user owns the order or holds a support role with extended rights. This keeps the guard configuration in the workflow itself declarative and short, while the actual, potentially complex authorization logic sits centrally in the voter and can be tested there independently of the workflow.


<?php
// src/Security/Voter/OrderCancelVoter.php
declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Order;
use App\Entity\User;
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. Practical example: an order stays cancellable only until it ships

A classic example of combining a structural transition with a business guard is an order's cancellation process: structurally the workflow definition allows the cancel transition from the pending state, because an already shipped or delivered order never reaches that state again anyway. From a business perspective that is not enough, though, because there is a time window between the order entering the pending state and the actual shipment during which the warehouse process may already have picked and packed the goods, without the order's marking state having changed because of that.

The guard listener from the previous section closes exactly this gap by checking an additional shippedAt field on the Order entity, independent of the marking state, which the warehouse process sets separately as soon as the package physically leaves the warehouse. This separation between workflow state and an additional, business-driven timestamp is a common pattern, because not every relevant business condition can sensibly be modeled as its own workflow state without needlessly bloating the state machine.

7. Multiple guard listeners and their execution order

A single transition can have several guard listeners registered at once, for example one for the permission check via the Security Voter and another for pure business logic such as shipping status. All registered listeners run on every can() or apply() call, regardless of whether an earlier listener has already blocked the transition, so that buildTransitionBlockerList() ultimately returns every blocking reason collected, not just the first one found.

The standard EventDispatcher priority controls the order in which listeners are evaluated, which matters when an expensive listener, for example one making an external API call, should only run if a cheaper pre-check has not already blocked the transition. Since all listeners generally keep running regardless, such an optimization has to be implemented explicitly inside the listener itself, for example by having the expensive listener first check whether the GuardEvent is already blocked and skip its own expensive check in that case.

8. Showing error messages correctly in the UI and the API

In a classic server-rendered Symfony application, the controller reads buildTransitionBlockerList() before the apply() call, turns every contained TransitionBlocker message into a flash message, and redirects the user back to the detail page where the messages become visible. It matters to use the TransitionBlocker instance's code here to show a matching icon or a suggested action where useful, for example a link to the support contact form for a permission block versus a plain status block.

In an API-driven application, for example built with API Platform, the TransitionBlocker codes can be returned directly as a structured error format with an HTTP 422 status, where every blocking reason appears as its own object with code and detail in the JSON body. The frontend can then react to these codes independently of the concrete, possibly translated message, which matters especially in multilingual applications, because the message text changes per locale while the code stays stable.

9. Testing guards and summary

Guard listeners can be tested in isolation without a full kernel boot by manually constructing a GuardEvent with a test subject and a test transition and passing it directly to the listener, after which the test only checks whether, and with which code, a TransitionBlocker was added. For the combination of workflow and guards as a whole, an additional functional test is worthwhile that fetches the real workflow service from the container and calls can() and buildTransitionBlockerList() for different subject states, to make sure the configuration and the listeners actually work together correctly.

In summary, combining from/to topology, guard events or TransitionBlocker, and Security Voters cleanly separates three distinct business questions: which transitions structurally exist, which additional business conditions must be met, and who is even allowed to trigger the transition in the first place. This separation makes workflows in Symfony noticeably more maintainable than one large if-cascade inside a service, because each of the three questions can be tested, changed and displayed differently in the frontend independently of the others.

Mechanism Purpose Defined where Testability
from/to on the transition Structural reachability of a state workflow.yaml Checkable per state via can()
Declarative guard expression Short, one-line business condition workflow.yaml (ExpressionLanguage) Checkable via can() with test subjects
Guard event listener Complex conditions with service dependencies EventListener class Testable in isolation with a manually built GuardEvent
Security Voter in the guard Role-based, reusable authorization Voter class Testable independently of the workflow via isGranted()

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

Workflow Guards: The Essentials at a Glance

Core problem

from/to only defines structural reachability, not the business permissibility of a transition.

Solution

Guard events and TransitionBlocker additionally block transitions and return meaningful failure reasons.

Role-based rules

Security Voters handle permission checks, referenced via is_granted() inside the guard.

Practical recommendation

Always check buildTransitionBlockerList() before apply() instead of catching the LogicException.

11. FAQ: Workflow Guards: The Essentials at a Glance

1What is the difference between the from/to definition and a guard?
from/to defines which state a transition is structurally reachable from at all. A guard additionally checks a business condition that must be met regardless of the current marking state, for example whether an order has already shipped.
2When should I use the declarative guard option instead of an event listener?
For short, one-line conditions without complex service dependencies, the declarative guard option in the YAML configuration is sufficient and clearer. Once several services need to be injected or external calls made, a dedicated event listener is the clearer choice.
3What does TransitionBlocker do differently than a plain setBlocked(true)?
Besides the blocking effect itself, TransitionBlocker carries a human-readable message and a stable, machine-readable code, letting the frontend or API display specific error messages or actions instead of just receiving a blanket blocked flag.
4Can a guard listener report multiple reasons at once?
Yes, a listener can register several TransitionBlocker instances through multiple addTransitionBlocker() calls when several conditions are violated at the same time. buildTransitionBlockerList() then returns all registered reasons collected together.
5Should permission logic live in the guard listener or in the Security Voter?
Permission logic belongs in a Security Voter, because it is reusable in controllers and templates and can be tested independently of the workflow. The guard then merely references the voter via is_granted(), without duplicating the logic.
6What happens if I call apply() while the transition is blocked?
apply() throws a LogicException as soon as at least one TransitionBlocker is registered. In practice, can() or buildTransitionBlockerList() should therefore be checked explicitly before every apply() call, so the concrete blocking reasons can be shown to the user.
7Do all guard listeners run even if one has already blocked the transition?
Yes, every registered listener for a transition generally runs, regardless of whether a previous listener has already added a TransitionBlocker. Anyone wanting to skip expensive checks has to implement that explicitly inside their own listener.
8Can I define guards for all transitions of a workflow at once?
Yes, via the generic workflow.guard event a listener can listen to every guard event across all configured workflows at once, while workflow.[name].guard.[transition] only fires for one specific transition of one specific workflow.
9How do I test a guard listener without a full kernel boot?
A GuardEvent can be constructed manually with a test subject, a transition and a marking instance and passed directly to the listener's __invoke() method. The test then only checks whether, and with which code, a TransitionBlocker was added.
10Is the extra effort of guards worthwhile for small workflows?
For very simple workflows with only two or three states and no additional business conditions, the plain from/to definition is often enough. As soon as permissions or time-dependent conditions such as a shipping status come into play, though, the clear separation guards provide pays off quickly.