New In Symfony 3.3 - Workflow Guard Expressions


As I was coming to the end of recording the videos on Symfony's Workflow Component I spotted a new feature coming in Symfony 3.3 - Guard Expressions.

We've taken a look at creating our own Workflow Guards, so why might we want to use a Guard Expression when we already have a solution to this problem?

The short answer to this is: simplicity.

As part of the Workflow Guard videos we saw:

  • how we would need to create a new PHP class;
  • then register this class as its own Symfony service;
  • then specify which event(s) to apply this guard too

With a Guard Expression, you can do away with the first and second steps entirely.

Instead, you would define the guard clause right inside your Workflow Definition. This brings the additional benefit of it being extremely obvious that this particular transition has a guard clause. When registering separate guard classes this is much less obvious.

Overall then, this sounds like a massive win.

Why not simply use Guard Expressions all the time then?

Well, let's look at an example or two, and then cover some other instances where a guard expression isn't going to work as well.

Examples

Directly from the commit where this feature was merged, there are two great code examples:

transitions:
    journalist_approval:
        guard: "is_fully_authenticated() and has_role('ROLE_JOURNALIST') or is_granted('POST_EDIT', subject)"
        from: wait_for_journalist
        to: approved_by_journalist
    publish:
        guard: "subject.isPublic()"
        from: approved_by_journalist
        to: published

Immediately we can see a "new thing". Rather than simply having from and to, we now also have guard.

Looking a little closer we can also see that we will have access to the subject.

The subject will be whatever object is currently traveling through your workflow. We will cover this with further examples below. For now you should know that this will be your object. Whatever public methods / properties that you expose on your object will be usable here.

There's also the use of is_fully_authenticated, has_role, and is_granted. You can read more on these in the official documentation.

However complex or simple you wish to make your guard expression logic, the ultimate outcome needs to be that you return either a true, or a false.

If the expression evaluates to false, the transition is blocked.

If your expression evaluates to true, then the transition can continue. It may still be blocked by another guard. You can have a guard expression AND the more traditional workflow guard services all guarding the same transition. If any of the clauses fail then the transition will be blocked.

This could be misleading to newcomers to your code base, so consider only using guard expressions when there are no other clauses guarding this particular transition.

Reimplemting The Blocked Countries Guard

To give a better understanding of how Symfony's Workflow Guard Expressions help simplify your code, consider the previous guard class that we created:

<?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(AuthorizationCheckerInterface $authorizationChecker)
    {
        $this->authorizationChecker = $authorizationChecker;
    }

    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');

        throw new LogicException('bad country');
    }

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

This is a lot of code for a simple check. I appreciate that this example is contrived, but stick with me as it does illustrate a few points.

We have a list of country codes - just one in this case, and it's hardcoded - that we wish to block user's from signing up from. In this case it's just Antarctica.

A bunch of cheeky penguins are using our sign up form to validate their stolen credit cards and we want to put an end to their antics. I jest about this, but you wouldn't believe how many times a day that baddies try to use fake credit cards to sign up to CodeReviewVideos. If only it were as simple as blocking Antarctica :/

Notice here that we can get access to the subject:

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

We know that in our workflow that the subject will actually be an instance of Customer. By defining this with the @var annotation, we can help PHPStorm (other IDE's are available) with better autocompletion.

Then a strict in_array check is made to determine if the customer's country code is in the blocked list. If so, we block the event - or in workflow parlance: guard against this transition.

One extra thing happening here is the throw'ing of the LogicException with a nicer error message. This is something we cannot do if using a guard expression.

We aren't finished here though. We would also need to define this as its own service. If you would like to know more on this, then please watch this video where this is covered in more depth.

After all this, we have a working workflow transition guard that blocks if a customer tries to sign up from Antarctica.

Now, the downside to this is that it's detached from your workflow definition. If you were a new developer to this project, not only would you need to read the workflow definition, but also the services.yml file, and then read the code in the class itself, matching the getSubscribedEvents logic up to the particular transitions in the project's code.

That is a lot of 'stuff' to do to simply understand we don't allow customers from Antarctica to join to our site.

Now, you might argue that hey, you're going to be reading the services.yml file anyway. And that you don't need to know how Antarctica customers are blocked, just that they are. But that's a different discussion.

Wouldn't it be nicer if we could replace all this with a one-liner, that's right there inside our workflows.yml file? Sure it would. Let's do just that:

# /app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            # other stuff removed
            transitions:
                sign_up:
                    from: prospect
                    to: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
                    guard: subject.getCountry() not in ["AQ"]

Yup. As easy as that.

Remember, our subject will be whatever object is traveling through our workflow. In this case, that object would be our customer.

Our customer has a public method called getCountry, which ... returns their country code.

Then we simply check if that country code is not in the given array, which simply contains one entry - AQ, the country code for Antarctica.

Now we could remove the entire CustomerSignUpGuard class, and the associated service definition.

A big win, right? Less code, less bugs, and all that.

I agree. But be careful.

If you need to check that same condition in multiple steps, you would end up duplicating the guard expression logic over all those steps. In this case, a fully fledged guard class would likely be better.

Likewise, we can't throw a custom LogicException, so we're stuck with the default "programmers" error message.

Anyway, these guard expressions are extremely useful and are available in Symfony 3.3 onwards. As ever, there are some trade offs, as discussed, but overall should make your life as a developer that much easier.

Code For This Course

Get the code for this course.

Episodes