How To: Transitions That Split Into Two Or More Places
In this video we are going to look at a more complex transition - involving what is more formally known as an 'AND-split', or a 'Parallel Split'. Proper names aside, I call this one "the one where we go from one place to two or more places".
To make it as clear as possible, I have removed every place and transition unrelated to this particular step, and as a result have the following workflow diagram:
Implementing this in our workflow definition is fairly straightforward when viewing in isolation:
# app/config/workflows.yml
framework:
workflows:
customer_signup:
supports: AppBundle\Entity\Customer
initial_place: prospect
places:
- !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
- awaiting_card_details
- awaiting_passport
transitions:
request_account_upgrade:
from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
to:
- awaiting_passport
- awaiting_card_details
This being YAML, our to
section may also be represented in bracket notation:
# app/config/workflows.yml
request_account_upgrade:
from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
to: [awaiting_passport, awaiting_card_details]
I prefer the former, you may prefer the latter. Choose one format and stick to it, there's no right or wrong.
We could just as easily split into three, four, or as many further places as needed. Just remember to add them to your list of places
before trying to transition to them :)
Now, this is why we must use the workflow type: workflow
, as now we can be in two or more places at any given time. As such, after successfully transitioning through request_account_upgrade
, assuming we have an entity property similar to the following:
// /src/AppBundle/Entity/Customer.php
/**
* @var array
*
* @ORM\Column(name="marking", type="json_array", nullable=true)
*/
private $marking;
Then inside our database would should expect to see the marking
column contain:
{"awaiting_passport":1,"awaiting_card_details":1}
There's no extra requirements involved in apply
ing this transition, e.g. from a controller action:
try {
$this
->get('workflow.customer_signup')
->apply($customer, 'request_account_upgrade')
;
$this->getDoctrine()->getManager()->flush();
} catch (LogicException $e) {
// whatever
}
And after passing through this transition when using the AuditTrailListener
we added and configured in the previous video, we should see expect to see something similar to:
$ tail -f var/logs/dev-workflow.log
[2017-02-27 12:38:49] app.INFO: Leaving "free_customer" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-27 12:38:49] app.INFO: Transition "request_account_upgrade" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-27 12:38:49] app.INFO: Entering "awaiting_passport" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-27 12:38:49] app.INFO: Entering "awaiting_card_details" for subject of class "AppBundle\Entity\Customer". [] []
Note here the second entering
entry.
At this stage, when combined with our existing steps, our workflow diagram looks as follows:
The config at this point:
# app/config/workflows.yml
framework:
workflows:
customer_signup:
supports: AppBundle\Entity\Customer
initial_place: prospect
places:
- prospect
- !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
- awaiting_card_details
- awaiting_passport
- paying_customer
transitions:
sign_up:
from: prospect
to: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
request_account_upgrade:
from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
to:
- awaiting_passport
- awaiting_card_details
approve_vip:
from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
to: paying_customer
How we now action the transition is entirely up to you.
We could apply
the transition when a controller action is triggered. Or we could apply a transition from a console command. Or you could register a listener which applies the transition whenever a certain event is dispatched... or countless other ways.
Using The Workflow Component With Twig
We're going to add some links to our twig templates that should only display if the Customer
is allowed to apply that particular transition.
Fortunately, the Workflow Component comes with three (as of Symfony 3.3 onwards, two before) functions for use in our twig templates by way of the WorkflowExtension
.
Twig extensions contain one or more functions we can call from our twig templates. They can be useful for extracting more advanced logic from our templates, enabling code re-use, and abstracting away this potentially complex functionality.
In the case of the Twig WorkflowExtension
, the three functions of workflow_can
, workflow_transitions
, and as of Symfony 3.3 workflow_has_marked_place
, are different names for methods we can also work with from a controller, or service:
// /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);
}
For clarity, we could use all three in a service, starting with injecting the workflow via the service definition:
# /app/config/services.yml
our_workflow_using_service:
class: AppBundle\Service\SomeImportantService
arguments:
- "@workflow.customer_signup"
And in the associated constructor:
<?php
namespace AppBundle\Service;
use Symfony\Component\Workflow\Workflow;
class SomeImportantService
{
/**
* @var Workflow
*/
private $workflow;
public function __construct(Workflow $workflow)
{
$this->workflow = $workflow;
}
Or via the container, in a controller:
$workflow = $this->get('workflow.customer_signup');
Then, no matter which way we got access to the Workflow, we could then call any of those methods:
// e.g. in the service
$this->workflow->can($subject, 'transition_name');
$this->workflow->getMarking($subject)->has('a_place_name_in_your_list_of_places');
// or in a controller
$this->get('workflow.customer_signup')->getEnabledTransitions($subject);
Now, one thing to be careful of here is that workflow_can
, and workflow_transitions
will also call any associated guards. We will cover this in more detail in a few videos time.
In our Twig template this would equate to:
<!-- /app/Resources/views/dashboard/index.html.twig -->
{# loop through the enabled transitions #}
<ul>
{% for transition in workflow_transitions(customer) %}
<li><a href="...">{{ transition.name }}</a></li>
{% else %}
<li>No actions available.</li>
{% endfor %}
</ul>
<!-- {% if workflow_has_marked_place(customer, 'free_customer') %} -->
<!-- or if using constants - note the double backslashes -->
{% if workflow_has_marked_place(customer, constant('AppBundle\\Entity\\Customer::FREE_CUSTOMER')) %}
<h3>Upgrade Your Account!</h3>
<p><a href="{{ path('request_account_upgrade') }}">Request Account Upgrade</a></p>
{% endif %}
<!-- not used in the video, but coming later -->
{% if workflow_can(customer, 'upgrade_customer') %}
<a href="{{ path('upgrade_account') }}" class="btn btn-success btn-lg">Upgrade my account!</a>
{% endif %}
In my experience so far, I have found workflow_has_marked_place
to be the most frequently used. I'm not a huge user of Twig though, more commonly using Symfony only as the back-end API these days.
For completeness, let's take a look at the action behind the path for request_account_upgrade
:
// /src/AppBundle/Controller/DashboardController.php
/**
* @Route("/request-account-upgrade", name="request_account_upgrade")
* @throws \InvalidArgumentException
* @throws \LogicException
*/
public function requestAccountUpgradeAction(UserInterface $customer)
{
try {
$this->get('workflow.customer_signup')->apply($customer, 'request_account_upgrade');
$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');
}