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:

symfony workflow happy path

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 (POSTs) 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 applying 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.

Code For This Course

Get the code for this course.

Episodes