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:

symfony workflow component parallel split example

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 applying 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:

symfony workflow component tutorial and split example

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

Code For This Course

Get the code for this course.

Episodes