All I Need Are Your Bank Account Details and Sort Code Number, Madam
In this video we are continuing on with the happy path through approving their credit card details. Rather than me explaining this using lots of words, instead lets take a quick look at the diagram:
We've covered all the green steps, in this video we are focusing on the second set of blue steps. In terms of configuring each of these transitions, the good news is there is nothing we haven't already covered.
There is, however, a difference when we get to card_details_approved
, but more on this as we go through.
I would strongly recommend that if you aren't doing so already, you follow along at this point by way of either copying the Workflow Definition I am working through in these videos, or an alternative but similar one of your own. The reasoning for doing so is that one of the trickiest parts - for me - when working with the Workflow Component has been in coming up with the names for the transitions, and the places.
I believe that only when working through this on your own will you find whether you prefer to start with your places
, and then come up with the transition
names. Or vice-versa.
Naming Transitions
For me, I have found that starting with defining as many of the transition
names as possible has been easiest. When coming up with the transition names my guideline has been to think about whether I "can" do something. There is a can
method available in the Workflow Component:
public function can($subject, $transitionName)
I have found that I primarily use can
by way of the Workflow Component Twig Extension:
{% if workflow_can(customer, 'upgrade_customer') %}
<a href="{{ path('upgrade_account') }}" class="btn btn-success btn-lg">Upgrade my account!</a>
{% endif %}
Whereas in controller or service code, I tend to simply try
/ catch
an apply
of the transition:
$this->get('workflow.customer_signup')->apply($customer, 'request_account_upgrade');
The reason for mentioning this is that thinking about whether you can
/ apply
a transition tends to lead to a certain type of transition name.
Can I request_account_upgrade
?
Can I add_passport
?
Can I approve_card_details
?
All of these sound ok to me. However, it's really easy to break from this convention, and come up with:
Can I automated_passport_approval
?
It just sounds wrong. My best advice here is to plan this out first with pen and paper.
This - like quite a lot of my experience when working with the Workflow Component - comes down to a human issue, rather than code. Your code will still work if your transitions and / or places are badly named, but working with the Workflow Component will become a little less intuitive.
Naming Places
That's how I go about naming transitions - and so far, it's been more of an art than a science. But what about places
?
Well, my strategy has been somewhat similar. Again there's some code that helped guide me here, by way of hasMarkedPlace
in the WorkflowExtension
file.
Let's assume we have a transition
of eat_meal
.
In order to eat our meal, we need a few things. We need:
- cutlery
- a table space
- some food
Though we end up with lolcats / cheezburger English, we might think to ask ourselves:
- has cutlery?
- has table space?
- has food?
However, in my experience, things become a little more complex than this. Likely we would have a process involved before we get to each of these potential places.
For cutlery, this may involve collecting_cutlery
, and collected_cutlery
.
Likewise, for a table space, this may involve searching_for_table_space
, found_table_space
, and reserved_table_space
.
If we aren't careful, things start to break down here with our has
approach:
- has
searching_for_table_space
? - has
collecting_cutlery
?
Maybe we would want to rename these slightly:
- has
begun_search_for_table_space
- has
located_cutlery
Again, as much like naming transitions, my experience has been that this is as much art as science. Nothing goes wrong - technically - if you name things badly, it just makes life that little more confusing.
However, all of this is why I say that you will get the most from this if following along with your own implementation. Only then will you have to engage your thought processes sufficiently to get this stuff to sink in. That said, you can always come back and rename things as needed. That's partly why I was suggesting the use of constants in the previous video.
Implementing The Transitions
We are going to cover two different approaches to applying a transition here. Firstly by way of a controller action, and the secondly by way of a Symfony Console Command.
There's nothing different about the two, other than how we get access to the particular workflow we are working with.
By the end of the previous video, we had added enough code to allow a customer to transition through request_account_upgrade
.
In doing so, they would end up in two places, best illustrated with code from the customer_signup
workflow definition:
# /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_passport
- awaiting_card_details
- 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
vip_approval:
from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
to: paying_customer
From our Twig template, we can now check if the customer has a specific place
, and only then show them a link to continue their journey towards paying_customer
.
This might look as follows:
<!-- /app/Resources/views/dashboard/index.html.twig -->
{% if workflow_has_marked_place(customer, 'awaiting_card_details') %}
<li><a href="{{ path('add_card_details') }}">Add Card Details</a></li>
{% endif %}
{% if workflow_has_marked_place(customer, 'card_details_awaiting_approval') %}
<li><i class="fa fa-question-circle"></i> Your card details are currently awaiting approval.</li>
{% endif %}
{% if workflow_has_marked_place(customer, 'card_details_approved') %}
<li><i class="fa fa-check"></i> Your card details were approved!</li>
{% endif %}
Each of these conditionals will only be met if the given customer
has the required place
. As our workflow ensures our customer can't end up in the both card_details_awaiting_approval
and card_details_approved
, we shouldn't ever encounter a situation whereby the customer sees more than one link at once.
To kick start this process, the customer must click the link to add their credit card details.
Before they can do this, let's add in the extra transitions:
# /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_passport
- awaiting_card_details
- card_details_awaiting_approval
- card_details_approved
- 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
vip_approval:
from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
to: paying_customer
add_card_details:
from: awaiting_card_details
to: card_details_awaiting_approval
decline_card_details:
from: card_details_awaiting_approval
to: declined
approve_card_details:
from: card_details_awaiting_approval
to: card_details_approved
In clicking this first link, they will be taken to a simple form - very simple in fact, just a submit button - that fakes the process of them providing their card details:
<!-- /app/Resources/views/dashboard/add-card-details.html.twig -->
% extends 'base.html.twig' %}
{% block body %}
<h1>Add Card Details</h1>
<form action="{{ path('submit_card_details') }}" method="post">
<button type="submit">Submit Card Details</button>
</form>
{% endblock %}
This form submits (POST
s) back to the following controller action:
// /src/AppBundle/Controller/DashboardController.php
/**
* @Route("/submit-card-details", name="submit_card_details")
* @Method("POST")
* @throws \InvalidArgumentException
* @throws \LogicException
*/
public function submitCardDetailsAction(UserInterface $customer)
{
try {
$this->get('workflow.customer_signup')->apply($customer, 'add_card_details');
$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');
}
You would likely have more complex logic going on here in a real world application, but the gist of what needs to happen is that we try
to apply
a given transition to a particular subject (a $customer
in our case), and if successfully, flush
(save) the change the to database.
In our example, we then redirect the customer back to the dashboard, whereby because they now have the place
of card_details_awaiting_approval
, they would trigger the correct conditional to see "Your card details are currently awaiting approval".
Note here that because we are in a controller, we can simply grab the particular workflow instance from the container via get
. In services, or if using your controller as a service, you would need to explicitly inject this service as we shall see shortly.
Assuming everything goes to plan here, the customer is correctly transitioned and their marking in the DB should now be:
{"awaiting_passport":1,"card_details_awaiting_approval":1}
To make things a little more interesting, at this point we require a back end user / staff member to approve their card details. This process is deliberate for the purposes of this example, purely to show apply
ing transitions from the perspective of a console command.
Firstly, let's take a look at the console command:
<?php
namespace AppBundle\Command;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CardDetailsReviewCommand extends ContainerAwareCommand
{
protected function configure()
{
$this
->setName('app:card-details-review')
->addArgument('username', InputArgument::REQUIRED, 'the customer username')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$username = $input->getArgument('username');
$io->writeln('Beginning card details approval process');
$em = $this->getContainer()->get('doctrine.orm.default_entity_manager');
// you would want an index on the 'username' field here in a real world application
$customer = $em->getRepository('AppBundle:Customer')->findOneBy([
'username' => $username
]);
if ($customer === null) {
$io->error(sprintf('Sorry, could not find the user "%s"', $username));
return false;
}
$workflow = $this->getContainer()->get('workflow.customer_signup');
$number = mt_rand(1,10);
try {
if ($number < 9) {
$workflow->apply($customer, 'approve_card_details');
$io->text(sprintf('User "%s" was approved.', $username));
} else {
$workflow->apply($customer, 'decline_card_details');
$io->warning(sprintf('User "%s" was declined.', $username));
}
} catch (\LogicException $e) {
$io->error(sprintf('Something went wrong: %s', $e->getMessage()));
return false;
}
$em->flush();
$io->success('Card details approval process completed.');
}
}
There's nothing too fancy going on here.
Our console command extends ContainerAwareCommand
, so we can still grab access to the required workflow instance (and the entity manager) without having to inject anything. An alternative to this approach is shown below.
Firstly we need to check that the given username
relates to a real, valid user. You would want to add an index to the username
field here, as this would potentially become a very slow operation as your application grew in size.
After this, a simple random number generator determines whether to approve or decline the given customer's card details.
As mentioned, you could instead inject the desired workflow:
<?php
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CardDetailsReviewCommand extends Command
{
/**
* @var Workflow
*/
private $workflow;
/**
* @var ObjectManager
*/
private $em;
/**
* SocialMediaPostHandler constructor.
*
* @param Workflow $workflow
* @param ObjectManager $em
*/
public function __construct(
Workflow $workflow,
ObjectManager $em
)
{
$this->workflow = $workflow;
$this->em = $em;
}
protected function configure()
{
$this
->setName('app:card-details-review')
->addArgument('username', InputArgument::REQUIRED, 'the customer username')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$username = $input->getArgument('username');
$io->writeln('Beginning card details approval process');
// you would want an index on the 'username' field here in a real world application
$customer = $this->em->getRepository('AppBundle:Customer')->findOneBy([
'username' => $username
]);
if ($customer === null) {
$io->error(sprintf('Sorry, could not find the user "%s"', $username));
return false;
}
$number = mt_rand(1,10);
try {
if ($number < 9) {
$this->workflow->apply($customer, 'approve_card_details');
$io->text(sprintf('User "%s" was approved.', $username));
} else {
$this->workflow->apply($customer, 'decline_card_details');
$io->warning(sprintf('User "%s" was declined.', $username));
}
} catch (\LogicException $e) {
$io->error(sprintf('Something went wrong: %s', $e->getMessage()));
return false;
}
$this->em->flush();
$io->success('Card details approval process completed.');
}
}
With the required config:
your_console_command_service_name_here:
class: AppBundle\Command\CardDetailsReviewCommand
arguments:
- "@workflow.customer_signup"
- "@doctrine.orm.entity_manager"
tags:
- { name: console.command }
Whichever way you choose to implement this, as we don't yet have the unhappy path setup, I would advise commenting out the conditional whereby we might end up in decline_card_details
.
Now, when running the command we should see our user end up in the place of card_details_approved
:
{"awaiting_passport":1,"approve_card_details":1}
Which should show the third message in our list of Twig conditionals: "Your card details were approved!"
As we haven't yet configured any further steps, we should now be 'done' for the moment. Our happy path is almost complete. We cannot yet upgrade_customer
, but even if we could, we have no way - yet - to get our customer to a place where they have an approved passport.