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.