Your Passport To Freedom


In this video we are going to implement the customer's journey through adding their passport. Much like in the previous video, we won't fully implement the process of uploading a passport image, instead faking that process with a simple form submission.

To make this process more interesting, we are going to again, implement a Symfony console command that determines - by way of a random number generator - whether the customer's passport is automatically approved, or requires processing via the manual approval process.

The manual approval process will expect us to log in as a different user, in the case one with the role of ROLE_ADMIN, who can then approve or decline our customer's passport.

To make it absolutely clear, the following diagram covers the transitions in this video:

symfony workflow complex paths example

We will be covering the blue steps, and setting up the process of handling the red steps.

Let's start by covering the next step after awaiting_card_details.

First, we need to add in the new 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_card_details
                - passport_awaiting_approval
                - passport_approved
                - manual_passport_review
                - declined
            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:
                        - !php/const:AppBundle\Entity\Customer::AWAITING_PASSPORT
                        - awaiting_card_details
                add_passport:
                    from: awaiting_passport
                    to: passport_awaiting_approval
                require_manual_passport_approval:
                    from: passport_awaiting_approval
                    to: manual_passport_review
                automated_passport_approval:
                    from: passport_awaiting_approval
                    to: passport_approved
                manual_passport_approval:
                    from: manual_passport_review
                    to: passport_approved
                decline_passport:
                    from: manual_passport_review
                    to: declined

For the sake of ease of reading, I have removed all the extra transitions and places so we can focus on only the new parts relevant to this section of the video.

There's still plenty happening here, so let's break each part down.

We already know about the first two transitions. They are the 'setup' steps required to get us to this part, so if unsure please watch from video #3 onwards.

Adding A Passport

Much like in the previous video, the journey for adding a passport begins with a simple form submission. This is almost identical, barring the transition name, to the process we went through when adding a credit card. For completeness, let's see it again:

<!-- /app/Resources/views/dashboard/add-passport.html.twig -->

{% extends 'base.html.twig' %}

{% block body %}
    <h1>Add Passport</h1>
    <form action="{{ path('submit_passport') }}" method="post">
        <button type="submit">Submit Passport</button>
    </form>
{% endblock %}

A very simple form - doesn't do anything, other than allow a submission to a particular path: submit_passport.

We need to define the controller action matching this path:

// /src/AppBundle/Controller/DashboardController.php

    /**
     * @Route("/add-passport", name="add_passport")
     */
    public function addPassportAction(Request $request)
    {
        return $this->render('dashboard/add-passport.html.twig');
    }

    /**
     * @Route("/submit-passport", name="submit_passport")
     * @Method("POST")
     * @throws \InvalidArgumentException
     * @throws \LogicException
     */
    public function submitPassportAction(Request $request, UserInterface $customer)
    {
        try {

            $this->get('workflow.customer_signup')->apply($customer, 'add_passport');
            $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');
    }

In order to kick start this process we need a way for our site visitor / customer to access this form. Again, this is very similar to the set of Twig conditionals we had for the passport details info:

<!-- /app/Resources/views/dashboard/index.html.twig -->

    <ul>
        {% if workflow_has_marked_place(customer, constant('AppBundle\\Entity\\Customer::AWAITING_PASSPORT')) %}
            <li><a href="{{ path('add_passport') }}">Add Passport</a></li>
        {% endif %}
        {% if workflow_has_marked_place(customer, 'passport_awaiting_approval') %}
            <li><i class="fa fa-question-circle"></i> Your passport is currently awaiting approval.</li>
        {% endif %}
        {% if workflow_has_marked_place(customer, 'passport_approved') %}
            <li><i class="fa fa-check"></i> Your passport was approved!</li>
        {% endif %}
    </ul>

In using these conditionals we can keep re-using the same dashboard layout, showing a different snippet of text (or a link) depending on what place they / the customer is currently marked with. In a larger / more real world application, you would likely have different pages, and that's fine.

Again now, once the customer clicks this link, the form is POSTed to submitPassportAction, which applies the required transition to the customer, which should - all being well - see the customer updated with a marking in the database of:

{"passport_awaiting_approval":1,"card_details_approved":1}

Much like in the credit card approval process, we have a Symfony Console Command that decides the next status / marking of our passport approval journey.

The command will, by way of a random number, decide whether to automatically approve the given passport, or set it aside from manual review.

This gives us the interesting workflow definition, indicated above where we have two workflow transitions beginning with the same from place:

#/app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            transitions:
                # from 1
                require_manual_passport_approval:
                    from: passport_awaiting_approval
                    to: manual_passport_review
                # from 2
                automated_passport_approval:
                    from: passport_awaiting_approval
                    to: passport_approved

We have already covered the Parallel Split / AND-split.

This transition is called an Exclusive Choice, or an exclusive OR-split.

The difference being that with an AND-split we end up in two (or more) places after the transition, whereas with an exclusive OR split, we only end up in one of the available places.

It's quite easy - in my experience - to over think this. It might not be immediately obvious that you can have two or more from places all pointing to the same place. However, you now know that this is possible.

Let's look at the code that applies one or the other of these two transitions:

<?php

// /src/AppBundle/Command/PassportReviewCommand.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 PassportReviewCommand extends ContainerAwareCommand
{
    protected function configure()
    {
        $this
            ->setName('app:passport-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 passport approval process');

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

        // you would want to add an index here, see previous video write up for more
        $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 < 7) {
                $workflow->apply($customer, 'automated_passport_approval');
                $io->text(sprintf('User "%s" was auto approved.', $username));
            } else {
                $workflow->apply($customer, 'require_manual_passport_approval');
                $io->warning(sprintf('User "%s" needs manual approval.', $username));
            }
        } catch (\LogicException $e) {
            $io->error(sprintf('Something went wrong: %s', $e->getMessage()));

            return false;
        }

        $em->flush();

        $io->success('Passport approval process completed.');
    }
}

Again, this is almost entirely the same as the console command we covered in the previous video. If you prefer console commands as services, again look at the example in the previous video for how to do so.

Now, at this point let's assume we run the command and our customer is one of the unlucky few, and is put into the place of require_manual_passport_approval. In the database we should now expect to see:

{"require_manual_passport_approval":1,"card_details_approved":1}

The customer has no control over this process. Much like when awaiting their credit card to be approved, they have to sit tight and await a back end process to take place before they can - potentially - continue.

In order to manually review the submitted passports, let's pretend we have a team of staff sat waiting to review any submissions that are flagged accordingly.

To simular this process, we have an admin dashboard that displays a list of passports, with the ability to approve or decline:

<!-- /app/Resources/views/passport/passport-review.html.twig -->

{% extends 'base.html.twig' %}

{% block body %}
    {% if passports_awaiting_review is empty %}
        There are no passports awaiting manual review.
    {% else %}
    <ul>
        {% for customer in passports_awaiting_review %}
        <li>
            {{ customer.username }} - 
            <a href="{{ path('manual_passport_approve', { id: customer.id }) }}">Approve!</a> : 
            <a href="{{ path('manual_passport_decline', { id: customer.id }) }}">Decline</a>
        </li>
        {% endfor %}
    </ul>
    {% endif %}
{% endblock %}

Because the line is overly long, I have split it onto a few lines for readability.

From this template we can discern a few details:

  • We must have a controller somewhere that's providing the data behind passports_awaiting_review
  • We have two routes: manual_passport_approve, and manual_passport_decline

Good news everyone! All this logic lives inside a single controller - the PassportReviewController. In a real world application this would likely be split off into a bunch of services, but it's an example and to keep things simple, it's all in one place:

<?php

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

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Workflow\Exception\LogicException;

/**
 * Class DefaultController
 * @package AppBundle\Controller
 * @Route("passport-review")
 */
class PassportReviewController extends Controller
{
    /**
     * @Route("/", name="passport_review")
     */
    public function listPassportsForReviewAction(Request $request)
    {
        $passports = $this->getDoctrine()->getRepository('AppBundle:Customer')
            ->findAllAwaitingManualPassportReview()
            ->getResult()
        ;

        return $this->render('passport/passport-review.html.twig', [
            'passports_awaiting_review' => $passports
        ]);
    }

    /**
     * @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');

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

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

        try {

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

            $this->getDoctrine()->getManager()->flush();

            $this->addFlash('warning', 'Declined');

        } catch (LogicException $e) {

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

        }

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

Largely this should look very familiar. We try to apply transitions, and whatever the outcome, we redirect and display a flash message.

The more interesting part is in the query for finding all the passports awaiting manual review:

    /**
     * @Route("/", name="passport_review")
     */
    public function listPassportsForReviewAction(Request $request)
    {
        $passports = $this->getDoctrine()->getRepository('AppBundle:Customer')
            ->findAllAwaitingManualPassportReview()
            ->getResult()
        ;

        return $this->render('passport/passport-review.html.twig', [
            'passports_awaiting_review' => $passports
        ]);
    }

We can see this goes off to the Customer repository and calls the method findAllAwaitingManualPassportReview. Let's look at that:

<?php

// /src/AppBundle/Entity/Repository/CustomerRepository.php

namespace AppBundle\Entity\Repository;

class CustomerRepository extends \Doctrine\ORM\EntityRepository
{
    public function findAllAwaitingManualPassportReview()
    {
        return $this->_em->getRepository('AppBundle:Customer')
            ->createQueryBuilder('c')
            ->where('c.marking LIKE :awaitingManualReview')
            ->setParameter('awaitingManualReview', '%manual_passport_review%') //eurghh
            ->getQuery()
        ;
    }
}

Yeah... hmmm.

The only way - it seems - to retrieve data from the array of places (marking) is to do a LIKE.

The downside to doing this becomes more evident as your table grows in size. Because manual_passport_review is prefixed with a %, this will cause a full table scan (to the very best of my knowledge) which will cause performance problems. As this marking field is being searched as if it were plain text, I can't see a way round using the % operators as both prefix and suffix. I am very possibly wrong here, so am open to correction, but at the time of writing / recording am not aware of a better way.

Anyway, assuming we do apply the transition of manual_passport_approval, we should now see:

{"passport_approved":1,"card_details_approved":1}

At this point we can allow our customer to request their account be fully upgraded to the status / marking of paying_customer.

Let's add that transition in:

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            initial_place: prospect
            places:
                - passport_approved
                - card_details_approved
                - paying_customer
            transitions:
                upgrade_customer:
                    from:
                        - passport_approved
                        - card_details_approved
                    to: paying_customer

Again, all others removed for clarity.

The interesting part here is we are now merging back from two (or more) places into one.

This has a formal name - the Simple Merge, or exclusive OR join.

In other words, when we have a customer marked with both the places of passport_approved AND card_details_approved, we are eligible to transition to paying_customer.

Once the customer has the required places, doing the transition is no different to what we have seen before many times by now.

First, we start by checking if the customer can apply the transition:

<!-- /app/Resources/views/dashboard/index.html.twig -->

    {% if workflow_can(customer, 'upgrade_customer') %}
    <a href="{{ path('upgrade_account') }}" class="btn btn-success btn-lg">Upgrade my account!</a>
    {% endif %}

Note that this will trigger any guard statements to run, if any are configured. Don't worry about this for now, just be aware of it.

Next, we need to define the controller action for upgrade_account:

// /src/AppBundle/Controller/DashboardController.php

    /**
     * @Route("/upgrade-account", name="upgrade_account")
     * @throws \InvalidArgumentException
     * @throws \LogicException
     */
    public function upgradeAccountAction(Request $request, UserInterface $customer)
    {
        try {

            $this->get('workflow.customer_signup')->apply($customer, 'upgrade_customer');
            $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');
    }

Again, nothing untoward here by now. Try and apply the transition, and either way, redirect to the dashboard.

However, if the transition is successfully applied, the customer will now have the marking of:

{"paying_customer":1}

Chances are we don't actually want them to see the dashboard anymore, as it's not going to show them anything useful.

Let's instead add a redirect to some cheesey stock photography as a reward for all their hard work:

// /src/AppBundle/Controller/DashboardController.php

    /**
     * @Route("/", name="dashboard")
     * @param Request       $request
     * @param UserInterface $customer
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function indexAction(Request $request, UserInterface $customer)
    {
        if ($this->get('workflow.customer_signup')->getMarking($customer)->has('declined')) {
            return $this->redirectToRoute('denied');
        }

        if ($this->get('workflow.customer_signup')->getMarking($customer)->has('paying_customer')) {
            return $this->redirectToRoute('success');
        }

        return $this->render('dashboard/index.html.twig', [
            'customer' => $customer
        ]);
    }

    /**
     * @Route("/denied", name="denied")
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function deniedAction()
    {
        return $this->render('dashboard/denied.html.twig');
    }

    /**
     * @Route("/success", name="success")
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function successAction()
    {
        return $this->render('dashboard/success.html.twig');
    }

I will leave the activity of designing a stunning success and denied template up to you. For my effort, have a watch of the video, it's award winning.

Anyway, at this stage we have successfully completed the happy path.

We need to figure out what happens when things go wrong. And for that, we have already laid some of the foundations, and will add the implementation in the next video.

Code For This Course

Get the code for this course.

Episodes