Workflow Guards - Part 2


In the previous video we saw how we can use the Symfony Workflow Component's Guards to stop transitions from occurring when they do not meet our specific criteria.

Whilst previously we looked at ways to block transitions in a more generic scope, in this video we will cover how to block specific transitions in specific workflows.

Conceptually this is the same, though likely the logic in determining whether to block specific transitions will be more complex.

For the purposes of this example, we will block customers who are coming from countries we cannot accept.

Along the way we will also cover a way to improve upon the generic guard error message we've seen so far.

More Specific Guards

The implementation here is not very robust, nor is it intended to be. We are simply expecting our customer to have provided some valid details (ho, ho) when signing up, and if they reside in one of our blocked countries then we won't allow them to request an account upgrade.

Now, this isn't quite as unrealistic as it may seem. We aren't doing any fancy Geo-IP checking or that sort of thing. There's actually no need - after all, we require their passports. Any lies should be spotted along the way.

Again, a Workflow Guard is no different to any other Symfony Event Subscriber implementation.

We can subscribe to multiple events in one class. We can also have multiple methods called for one event (just pass in an array as the value).

The code explains this fairly clearly:

<?php

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

namespace AppBundle\Event\Subscriber;

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

class CustomerSignUpGuard implements EventSubscriberInterface
{
    public function guardAgainstBlockedCountries(GuardEvent $event)
    {
        $blockedCountries = [
            'AQ'
        ];

        /**
         * @var $customer Customer
         */
        $customer = $event->getSubject();

        $customerIsInBlockedCountry = in_array(
            $customer->getCountry(),
            $blockedCountries,
            true
        );

        if ($customerIsInBlockedCountry === false) {
            return;
        }

        $event->setBlocked('true');
    }

    public static function getSubscribedEvents()
    {
        return [
            // workflow.[workflow name].guard.[transition name]
            'workflow.customer_signup.guard.request_account_upgrade' => 'guardAgainstBlockedCountries'
        ];
    }
}

Sure, you might want to setup your blocked countries list as a parameter, and pass in the $blockedCountries via the constructor. In the interests of keeping it simple, it's hardcoded here.

The interesting part here is that this guard will only be triggered for a very specific transition: request_account_upgrade.

Looking back at our workflow definition:

# /app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            places:
                # * snip *
            transitions:
                request_account_upgrade:
                    from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
                    to:
                        - !php/const:AppBundle\Entity\Customer::AWAITING_PASSPORT
                        - awaiting_card_details
                # * snip *

Essentially we are saying the only time we want to check if the customer is coming from a blocked country is when requesting the account upgrade. The customer's country is not relevant to any other transition request.

At this point, the guard behaves exactly like any other guard - generic, or specific. If the $event->setBlocked(true); call is made, this transition is blocked.

In our case, we would then show a flash message with the given reason for being unable to apply this transition:

"Unable to apply transition "request_account_upgrade" for workflow "customer_signup"

A lovely programmer's error message there.

Chances are, our user's are not programmers.

We would more than likely want to show them a better error. There are countless ways to do this. However, we already have our try / catch setup, e.g:

    /**
     * @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');
    }

So, rather than change this we could simply throw our own LogicException instead.

Now - I'm not 100% sure this is the correct way to go about this, so use your own judgement, and shout up if you know otherwise. But this does work, and is in line with the way the Workflow Component works currently:

<?php

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

use Symfony\Component\Workflow\Exception\LogicException;

class CustomerSignUpGuard implements EventSubscriberInterface
{
    public function guardAgainstBlockedCountries(GuardEvent $event)
    {
        // * snip *

        if ($customerIsInBlockedCountry === false) {
            return;
        }

        $event->setBlocked('true');

        // throw here
        // note this is an instance of
        // Symfony\Component\Workflow\Exception\LogicException
        throw new LogicException('bad country');
    }

    public static function getSubscribedEvents()
    {
        return [
            // workflow.[workflow name].guard.[transition name]
            'workflow.customer_signup.guard.request_account_upgrade' => 'guardAgainstBlockedCountries'
        ];
    }
}

Everything else remains the same, but now we throw our own LogicException, which we can provide the message for. As such, our catch block from earlier:

        } catch (LogicException $e) {
            $this->addFlash('danger', sprintf('No, that did not work: %s', $e->getMessage()));
            $this->get('logger')->error('Yikes!', ['error' => $e->getMessage()]);
        }

Will now swap from:

No, that did not work: "Unable to apply transition "request_account_upgrade" for workflow "customer_signup"

to

No, that did not work: "bad country"

Use a better error message, of course. But still, in my opinion it's an improvement.

Triggered

One last point with guards is that they may be triggered in unexpected circumstances.

As you will have seen in the videos, this can lead to some undesirable outcomes, especially if wanting to display flash messages or similar.

This becomes particularly relevant if using the Workflow Component with the provided Twig Extension.

As covered in a previous video, the Twig Extension for the Workflow Component provides access to three of the Workflow methods:

// /vendor/symfony/symfony/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php

    public function getFunctions()
    {
        return array(
            new \Twig_SimpleFunction('workflow_can', array($this, 'canTransition')),
            new \Twig_SimpleFunction('workflow_transitions', array($this, 'getEnabledTransitions')),
            new \Twig_SimpleFunction('workflow_has_marked_place', array($this, 'hasMarkedPlace')),
        );
    }

    public function canTransition($object, $transition, $name = null)
    {
        return $this->workflowRegistry->get($object, $name)->can($object, $transition);
    }

    public function getEnabledTransitions($object, $name = null)
    {
        return $this->workflowRegistry->get($object, $name)->getEnabledTransitions($object);
    }

    public function hasMarkedPlace($object, $place, $name = null)
    {
        return $this->workflowRegistry->get($object, $name)->getMarking($object)->has($place);
    }

And as covered in the previous video, some of these methods will trigger a testing of any configured guards. These methods are:

  • workflow_can
  • workflow_transitions

To understand why this is, you need to look at the code for these functions.

Starting with workflow_transitions, this really called getEnabledTransitions:

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

    /**
     * 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;
    }

As part of this method, a call to doCan is made:

if ($this->doCan($subject, $marking, $transition)) {

If we look at doCan, we can see this triggers a guard check:

    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;
    }

Likewise, workflow_can inside Twig really calls can inside Workflow.php:

    /**
     * Returns true if the transition is enabled.
     *
     * @param object $subject        A subject
     * @param string $transitionName A transition
     *
     * @return bool true if the transition is enabled
     */
    public function can($subject, $transitionName)
    {
        $transitions = $this->getEnabledTransitions($subject);

        foreach ($transitions as $transition) {
            if ($transitionName === $transition->getName()) {
                return true;
            }
        }

        return false;
    }

As we can see, can first calls getEnabledTransitions, which as we have just seen, will end up checking our guards.

The only one that doesn't is workflow_has_marked_place. This actually calls getMarking, which you can see here.

Code For This Course

Get the code for this course.

Episodes