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:
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:
- They can remain as a free customer
- They can request an account upgrade to become a paying customer
- 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:
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 apply
ing 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.