Workflow Guards - Part 1


In this video we are getting started with Workflow Guards.

The concept behind a Workflow Guard is simple enough: We want to block a transition if some criteria that we set is not met.

This criteria can be as simple, or as complex as you require.

As part of any transition, a number of events will be dispatched. We will cover more of these events in an upcoming video.

The events we are interested in, directly from the documentation:

  • workflow.guard
  • workflow.[workflow name].guard
  • workflow.[workflow name].guard.[transition name]

Let's quickly cover how these are would occur, then see some code.

Firstly, all three events will be dispatched before any successful transition. Again, this will become clearer when we see some code.

Imagine we have the following workflow definitions:

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            places:
                - etc
            transitions:
                abc:
                    from: a
                    to: c
                cde:
                    from c
                    to: e
        data_import:
            supports: AppBundle\Entity\SomethingElse
            places:
                - etc
            transitions:
                lmn:
                    from: l
                    to: n
                nop:
                    from n
                    to: p

workflow.guard - you would listen for this event to guard any transition. This would affect every single workflow definition you have defined.

Given the above, a configured guard for the event of workflow.guard would run for all transitions. In other words:

  • abc
  • cde
  • lmn
  • nop

workflow.[workflow name].guard becomes more specific. We would need to replace [workflow name] with our specific workflow. Let's say we guard workflow.data_import.guard. This would mean our guard will run for the following transitions:

  • lmn
  • nop

It's more specific. It's only interested in the specific workflow definition - data_import - that we need it to be.

Finally, we have workflow.[workflow name].guard.[transition name] which is useful for guarding a specifc transition inside a specific workflow.

Imagine we have workflow.customer_signup.guard.cde, which - you guessed it - would guard only:

  • cde

Ok, being told this is one thing. Knowing where to look in the code is more useful.

For this we need to look at the Workflow.php file.

We know we can apply a transition. This method lives inside Workflow.php:

// /vendor/symfony/symfony/src/Symfony/Component/Workflow/Workflow.php

    /**
     * Fire a transition.
     *
     * @param object $subject        A subject
     * @param string $transitionName A transition
     *
     * @return Marking The new Marking
     *
     * @throws LogicException If the transition is not applicable
     * @throws LogicException If the transition does not exist
     */
    public function apply($subject, $transitionName)

The very first thing this method does is to getEnabledTransitions:

    public function apply($subject, $transitionName)
    {
        $transitions = $this->getEnabledTransitions($subject);

Which again, lives inside the Workflow class:

    /**
     * Returns all enabled transitions.
     *
     * @param object $subject A subject
     *
     * @return Transition[] All enabled transitions
     */
    public function getEnabledTransitions($subject)
    {
        $enabled = array();
        $marking = $this->getMarking($subject);

        foreach ($this->definition->getTransitions() as $transition) {
            if ($this->doCan($subject, $marking, $transition)) {
                $enabled[] = $transition;
            }
        }

        return $enabled;
    }

For each transition, another method is called - doCan.

But wait, there's more.

doCan first does some additional checks to see if this transition is even possible, and then ... ahoy, what's this?

    private function doCan($subject, Marking $marking, Transition $transition)
    {
        foreach ($transition->getFroms() as $place) {
            if (!$marking->has($place)) {
                return false;
            }
        }

        if (true === $this->guardTransition($subject, $marking, $transition)) {
            return false;
        }

        return true;
    }

Ok, now we're getting somewhere. This conditional is a little confusing:

if (true === $this->guardTransition($subject, $marking, $transition)) {
    return false;
}

If true, return false ;)

This makes more sense when we see, finally:

    /**
     * @param object     $subject
     * @param Marking    $marking
     * @param Transition $transition
     *
     * @return bool|void boolean true if this transition is guarded, ie you cannot use it
     */
    private function guardTransition($subject, Marking $marking, Transition $transition)
    {
        if (null === $this->dispatcher) {
            return;
        }

        $event = new GuardEvent($subject, $marking, $transition);
        $this->dispatcher->dispatch('workflow.guard', $event);
        $this->dispatcher->dispatch(sprintf('workflow.%s.guard', $this->name), $event);
        $this->dispatcher->dispatch(sprintf('workflow.%s.guard.%s', $this->name, $transition->getName()), $event);

        return $event->isBlocked();
    }

At this point I was going to include a cheesey meme about "this is where the magic happens". Only, it's not 1995, MTV Cribs is no longer popular, and ... there's no magic here.

Ok, but seriously - why:

true === $this->guardTransition($subject, $marking, $transition)

Because: return $event->isBlocked();

If the transition is blocked then we want doCan to be "no can do". In other words, if blocked then we can't do this transition.

Right, anyway here we can see where all of those guard events are being dispatched.

Firstly, however, if there is no configured dispatcher then none of these events will be dispatched. That's fair enough - but as we are using the Symfony framework, we will have this preconfigured, so don't worry about that. This is only there because you can use the Workflow Component in a standalone capacity, in which case you might not want or need an event dispatcher.

Taking the three lines in turn, firstly all three need the GuardEvent:

$event = new GuardEvent($subject, $marking, $transition);

And then:

$this->dispatcher->dispatch('workflow.guard', $event);

We've covered how you can 'match' any transition. This is as generic as it gets. Register a listener / guard for 'workflow.guard' and if the transition is successful, you will receive this event.

$this->dispatcher->dispatch(sprintf('workflow.%s.guard', $this->name), $event);

This is very similar to the first dispatch, only this time we use sprintf to insert the current workflow definition name into the string representing the event name.

This name property comes from the constructor:

public function __construct(
  Definition $definition,
  MarkingStoreInterface $markingStore = null,
  EventDispatcherInterface $dispatcher = null,
  $name = 'unnamed')
{
    $this->definition = $definition;
    $this->markingStore = $markingStore ?: new MultipleStateMarkingStore();
    $this->dispatcher = $dispatcher;
    $this->name = $name;
}

I've changed the formatting to fit on screen more easily.

Now, don't just take my word for this. We can actually look inside the container to see this name property being passed in:

// /var/cache/acceptance/appAcceptanceDebugProjectContainer.php

class appAcceptanceDebugProjectContainer extends Container
{
    private $parameters;
    private $targetDirs = array();

    /**
     * Constructor.
     */
    public function __construct()
    {
        $dir = __DIR__;
        for ($i = 1; $i <= 4; ++$i) {
            $this->targetDirs[$i] = $dir = dirname($dir);
        }
        $this->parameters = $this->getDefaultParameters();

        $this->services = array();
        $this->methodMap = array(
            // many, many other things removed
            'workflow.social_media_post' => 'getWorkflow_SocialMediaPostService',
        );
    }

    // * snip *

    /**
     * Gets the 'workflow.social_media_post' service.
     *
     * This service is shared.
     * This method always returns the same instance of the service.
     *
     * @return \Symfony\Component\Workflow\Workflow A Symfony\Component\Workflow\Workflow instance
     */
    protected function getWorkflow_SocialMediaPostService()
    {
        return $this->services['workflow.social_media_post'] = new \Symfony\Component\Workflow\Workflow(new \Symfony\Component\Workflow\Definition(array(0 => 'queued', 1 => 'sending', 2 => 'retrying', 3 => 'failed', 4 => 'sent'), array(0 => new \Symfony\Component\Workflow\Transition('begin_sending', array(0 => 'queued'), array(0 => 'sending')), 1 => new \Symfony\Component\Workflow\Transition('mark_as_sent', array(0 => 'sending'), array(0 => 'sent')), 2 => new \Symfony\Component\Workflow\Transition('begin_retrying', array(0 => 'sending'), array(0 => 'retrying')), 3 => new \Symfony\Component\Workflow\Transition('mark_as_failed', array(0 => 'sending'), array(0 => 'failed'))), 'queued'), new \Symfony\Component\Workflow\MarkingStore\SingleStateMarkingStore('status'), $this->get('debug.event_dispatcher', ContainerInterface::NULL_ON_INVALID_REFERENCE), 'social_media_post');
    }

Ok, fairly difficult to see, but it's all there. I would advise you open this file for your own project, and see for yourself.

Note that here, this is a service from a completely different project - actually the first time I ever started using the Workflow Component for real :)

But anyway, there you can see the final argument passed in is the name, in this case social_media_post, which is the name of my workflow.

$this->dispatcher->dispatch(sprintf('workflow.%s.guard.%s', $this->name, $transition->getName()), $event);

Lastly, we have something very similar. This time the sprintf call takes two arguments.

We already know about $this->name.

$transition->getName() is hopefully fairly self explanatory :)

After all this, what might not be obvious is that all three dispatch calls are going to happen, whether you have registered any guards / listeners or not.

When Is Any Of This Useful?

Each of the guards has a specific use case, as best I have found.

I've found the generic workflow.guard, and workflow.[workflow_name].guard are useful for permissions checking.

If you need a user to have a specific security role, they probably need this role to do any of the transitions. Not always, but this has been my primary use case.

Let's create a guard that listens for either of these two events, and blocks if we are not logged in.

To create our guard, we need a class that implements EventSubscriberInterface. Implementing this interface simply means we are ensuring that we have public static function getSubscribedEvents():

<?php

// /src/AppBundle/Event/Subscriber/CustomerSignUpGuard.php

namespace AppBundle\Event\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CustomerSignUpGuard implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return [
        ];
    }
}

Where you choose to put this class in your project's directory structure is entirely up to you. I've chosen to place it alongside any other event subscribers I would have in my project.

Now, all we need to do here is have a key / value pair, where the key is the name of the event to guard against, and the value is the method to be called when that event is dispatched.

This isn't something specific to guards in any way. This is how you would configure any event subscriber.

<?php

// /src/AppBundle/Event/Subscriber/CustomerSignUpGuard.php

namespace AppBundle\Event\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CustomerSignUpGuard implements EventSubscriberInterface
{
    public function onTransitionRequest(GuardEvent $event)
    {
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.guard' => 'onTransitionRequest',
            // and / or
            'workflow.customer_signup.guard' => 'onTransitionRequest',
        ];
    }
}

I've put and / or in the comment because you could guard against both, but it would be redundant.

My advice would be to be specific. Use workflow.customer_signup.guard (switching out for your own workflow name), as this leads to less confusion if adding another workflow in the future.

Now, I've also gone ahead and created a new method - onTransitionRequest - which will get called whenever our event is dispatched.

This onTransitionRequest method will be called [with the GuardEvent object4] we saw being dispatched earlier:

// /vendor/symfony/symfony/src/Symfony/Component/Workflow/Workflow.php

    private function guardTransition($subject, Marking $marking, Transition $transition)
    {
        // * snip *

        $event = new GuardEvent($subject, $marking, $transition);
        $this->dispatcher->dispatch('workflow.guard', $event);

This means we can grab access to the $subject, $marking, and $transition from that class.

In most cases the $subject is going to be the most interesting piece available to us. Thinking back to previous ways in which we have tried to apply transitions, we might have something like this:

    /**
     * @Route("/submit-passport", name="submit_passport")
     * @Method("POST")
     * @throws \InvalidArgumentException
     * @throws \LogicException
     */
    public function submitPassportAction(Request $request, UserInterface $customer)
    {
        try {
            $this->get('workflow.customer_signup')->apply($customer, 'add_passport');
            $this->getDoctrine()->getManager()->flush();
        } catch (LogicException $e) {
            $this->addFlash('danger', sprintf('No, that did not work: %s', $e->getMessage()));
            $this->get('logger')->error('Yikes!', ['error' => $e->getMessage()]);
        }

        return $this->redirectToRoute('dashboard');
    }

The interesting line here being:

$this->get('workflow.customer_signup')->apply($customer, 'add_passport');

Where $customer is going to be our $subject.

This means we can grab our $customer by calling $event->getSubject().

If we have access to the $customer, which in our application is an instance of UserInterface, or a valid User, we could check if that customer / user has the expected security role.

In order to do this, we need the AuthorizationChecker. We can have this dependency injected for us by way of the constructor, so long as we define CustomerSignUpGuard as a service. We're going to need to define CustomerSignUpGuard as a service anyway because otherwise, Symfony would be completely unaware that we wanted to listen for / guard against specific events.

# app/config/services.yml

services:
    workflow.customer_signup.guard_subscriber:
        class: AppBundle\Event\Subscriber\BlockedCountriesGuard
        arguments:
            - "@security.authorization_checker"
        tags:
            - { name: kernel.event_subscriber }

Now, we need to setup the __construct method, and then add in some logic to stop the transition from taking place if we are missing the expected security role:

<?php

// src/AppBundle/Event/Subscriber/CustomerSignUpGuard.php

namespace AppBundle\Event\Subscriber;

use AppBundle\Entity\Customer;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Exception\LogicException;

class CustomerSignUpGuard implements EventSubscriberInterface
{
    /**
     * @var AuthorizationCheckerInterface
     */
    private $authorizationChecker;

    public function __construct(FlashBagInterface $flashBag, AuthorizationCheckerInterface $authorizationChecker)
    {
        $this->authorizationChecker = $authorizationChecker;
    }

    public function onTransitionRequest(GuardEvent $event)
    {
        if ($this->authorizationChecker->isGranted('ROLE_USER') === false) {
            $event->setBlocked(true);
        }
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.customer_signup.guard' => 'onTransitionRequest',
        ];
    }
}

And now, whenever we request a transition in any step of the customer_signup workflow definition, our guard will be called ensuring we have the required security role of ROLE_USER.

Code For This Course

Get the code for this course.

Episodes