Creating More Complex Workflows


In this video we start our look at a more complex workflow. The idea behind this workflow is to manage the process that a new customer might transition through, in order to become a fully paying customer of our site.

Let's start by taking a look at our workflow diagram, as it will look after we have implemented all our transitions:

codereview videos workflow diagram example

In order to become a paying customer, firstly a user must sign up for an account. In doing so, they transition through sign_up, whereby they go from being a prospect, to become a free_customer.

At this point our (imaginary) business rules tell us three things:

  1. They can remain as a free customer
  2. They can request an account upgrade to become a paying customer
  3. They can bypass the standard upgrade process via VIP approval, and become a paying customer that way

The example is somewhat contrived - VIP or not, we would very much likely still want / need their payment details. But this should be enough to illustrate the various rules that might be put in place.

The VIP approval process is very simple. We skip all the complexity and transition the customer from free_customer directly to paying_customer.

Most of our users won't be quite so lucky.

Instead, they will need to go through an enjoyable process of uploading their passport, and adding in their bank details. This involves splitting the customer's journey into two different places concurrently.

In our demo application both the process of uploading a passport, and adding credit card details will be faked. A simple form and submit button will be enough to simulate the process.

We will have two Symfony console commands which can be run against any given username, which will determine if their passport, and credit card details were accepted or declined. Random number generators at the ready.

If particularly unlucky, a passport may require manual approval. This involves a manual review process by a different user. We will implement this along the way.

If everything goes to plan, our customer is eligible for their requested account upgrade, at which point they will transition from both card_details_approved, and passport_approved through upgrade_customer to become a paying_customer.

However, if certain criteria are not met, our unlucky customers will be placed in to a declined state. This involves a little extra complexity which will be discussed, and various options for us as developers will be explored.

In implementing this workflow we will touch on the most common transitions I have encountered when working with the Symfony Workflow component. I appreciate that when looking at a busy workflow diagram like this, it can be overwhelming. However, we will break down each step, and build this workflow definition back up piece-by-piece and by the end, I hope, you will agree that it's not that complicated after all.

We shall also cover workflow guards, and events. Both of these concepts are useful, and at the time of writing, events are not yet documented, so hopefully I can do some justice to the process here :)

We will cover the three available Twig extensions (think: functions you can call from within your Twig templates) and some potential gotchas around these also.

And along the way we will learn how to log out information from our workflows. This will include covering the AuditTrailListener that comes with the Workflow Component, as well as implementing our own interpretation of this listener, and why you might wish to do so.

There's plenty to cover, so let's get started.

Getting Started With Workflows

To kick things off, we are going to implement the sign_up transition. This should be entirely familiar - it's largely identical to the transition we implemented in the previous video.

The primary difference here is that we are implementing a workflow type of workflow, rather than a type of state_machine.

What's the difference between the two? A state_machine may only ever be in one place at any given time. As mentioned above, in our journey if the free_customer transitions through request_account_upgrade, they will be in the places of awaiting_passport, and awaiting_card_details at the same time. Therefore, we must use a workflow.

Let's take a quick look at our starting point, by way of the generated workflow diagram:

customer sign_up workflow transition

From which we can discern the following:

We have two places: prospect, and free_customer.

We have a single transition: sign_up.

There is another sneaky piece of info we could determine here: this is a workflow type of workflow. If this were a state_machine, we wouldn't see the middle square. If our workflow is of type: workflow then when dumping we go through the GraphvizDumper. If using type: state_machine, then instead we pass through StateMachineGraphvizDumper. As best I understand it, the StateMachineGraphvizDumper doesn't show the square transition steps as there will only ever be one next step. This isn't something that's documented, so I have had to discern this from reading various pull request comments, so apologies if this is an incorrect conclusion.

The workflow definition at this stage is largely similar to what we had in the previous video also:

# /app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            places:
                - prospect
                - free_customer
            transitions:
                sign_up:
                    from: prospect
                    to: free_customer

Now, here's an interesting situation:

Even though we don't have an initial_place, if we were to try and apply the sign_up transition to a Customer entity, this would work. This is somewhat confusing, in my opinion. After all, we have not explicitly stated our Customer is marked with the place of prospect.

Well, whatever is the first entry in the list of places will also be considered our initial_place.

I'm no fan of the implicit, if a more explicit alternative exists. Sure, it leads to a lot of extra 'stuff' on screen, but at least it's obvious where things are coming from. Anyway, we can be explicit here as we already know, by using initial_place:

# /app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            initial_place: prospect
            places:
                - prospect
                - free_customer
            transitions:
                sign_up:
                    from: prospect
                    to: free_customer

At this point we should be able to apply this transition to our Customer, but unless we tell Doctrine about our changes (by way of a flush), then nothing is going to get saved to the database. Let's look at this in more detail.

Don't Forget To Flush

Simply setting an initial_place, or applying a transition won't magically save that information off to your database. These are two totally separate processes.

Whilst that seems obvious when told directly, if you are head down in your code and perhaps doing too much at once, you could be forgiven for becoming confused.

Always remember to flush your changes to the database after applying a transition.

This is the same whether you are in a controller action:

// /src/AppBundle/Controller/PassportReviewController.php

    /**
     * @Route("/approve/{id}", name="manual_passport_approve")
     * @throws \LogicException
     */
    public function manuallyApprovePassportAction(Request $request, $id)
    {
        $passport = $this->getDoctrine()
            ->getRepository('AppBundle:Customer')
            ->find($id)
        ;

        try {

            $this
              ->get('workflow.customer_signup')
              ->apply($passport, 'manual_passport_approval')
            ;

            // *** DON'T FORGET TO FLUSH!!! ***
            $this->getDoctrine()->getManager()->flush();

            $this->addFlash('success', 'Approved');

        } catch (LogicException $e) {

            $this->addFlash('danger', sprintf('No, that did not work: %s', $e->getMessage()));

        }

        return $this->redirectToRoute('passport_review');
    }

Or a Symfony service:

/src/AppBundle/Command/PassportReviewCommand.php

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $username = $input->getArgument('username');

        $em = $this->getContainer()->get('doctrine.orm.default_entity_manager');

        $customer = $em->getRepository('AppBundle:Customer')->findOneBy([
            'username' => $username
        ]);

        if ($customer === null) {
            return false;
        }

        $workflow = $this->getContainer()->get('workflow.customer_signup');

        try {

            $workflow->apply($customer, 'automated_passport_approval');

            // *** DON'T FORGET TO FLUSH!!! ***
            $em->flush();

        } catch (LogicException $e) {

            return false;
        }
    }

Note here I have stripped out a bunch of output from this command to make the important code more obvious. Also, as only using the $workflow variable once, this could be inlined with the apply call, but it does make it that little more obvious what that service is for the purposes of this write up.

In this console command example this command would extend ContainerAwareCommand. More commonly I would define my console commands as services, which brings with it a little extra configuration but offers more flexibility and freedom.

At this point the outcome of our transition should have been successfully saved off to the database.

Assuming we have a property of our Customer entity called marking, which may look like this:

// /src/AppBundle/Entity/Customer.php

    /**
     * @var array
     *
     * @ORM\Column(name="marking", type="json_array", nullable=true)
     */
    private $marking;

Then looking in the database for this particular user at this point should show a marking entry as:

{"free_customer":1}

This is stored as JSON for the purposes of readability. This translates to the following PHP array:

[ "free_customer" => 1 ]

// or, if you are oldschool:

array( "free_customer" => 1 )

Why is this important?

Well, if you ever want or need to manually alter the value stored in your marking field (or whatever name you have given it), then you must ensure to use this format. In other words:

[ "{your place name here}" => 1 ]

// or, if you are still oldschool:

array( "{your place name here}" => 1 )

Obviously updating {your place name here} with whatever place you want to use. Likewise, if you need to go to multiple places at once, just add another key to the array with the additional place name, and a value of 1.

[ "free_customer" => 1, "another_place" => 1, "and_another" => 1 ]

// seriously, short array syntax has been in PHP since 5.4, which went
// end of life in September 2015... upgrade, please!

array( "free_customer" => 1, "another_place" => 1, "and_another" => 1 )

If you don't get this right, you can expect some fun errors:

  [Symfony\Component\Debug\Exception\FatalThrowableError]
  Type error: Argument 1 passed to Symfony\Component\Workflow\Marking::__construct() must be of the type array, string given, called in /Users/Shared/Development/symfony-workflow
  -example/vendor/symfony/symfony/src/Symfony/Component/Workflow/MarkingStore/MultipleStateMarkingStore.php on line 47

or

  [Symfony\Component\Workflow\Exception\LogicException]
  Place "0" is not valid for workflow "customer_signup".

Anyway, at this point we have covered the basics along with some of the potential gotchas that you may / will likely encounter as we continue through this series.

We're already starting to see two problems. The first is that our logs, by default, don't contain much in the way of helpful data during any particular transition.

The second is that we are relying on strings for everything, which will bite us as our workflows grow in size. We will therefore address both of these points in the very next video.

Code For This Course

Get the code for this course.

Episodes