How to Verify Phone Numbers with PHP, Symfony and Twilio

November 09, 2017
Written by
Chris Lush
Contributor
Opinions expressed by Twilio contributors are their own

symfony_logo

In this post we’ll learn how to verify phone numbers with Twilio in a Symfony project. We will discover how to model and validate a user’s phone number, and then use Twilio’s PHP SDK to create a call flow where the user has to enter a 6 digit code to verify themselves. The frontend view will provide a form to capture their number, displaying further instructions or validation errors, and then seamlessly redirect the user to another page once they’ve been verified.

If you need a refresher on Symfony, check out creating a Symfony 3 project with basic user handling. Those of you who don’t use Symfony should be able to carry the core ideas across to your framework of choice. Knp University’s screencast on Joyful Development with Symfony 3 is also helpful for starting a new project.

Check out this example project on GitHub with the code to the verification process that grants user access to premium blog posts.

Getting started

There are 4 steps you need to take before we begin verifying phone numbers:

Installing dependencies

You’ll need to install a couple of extra dependencies in your Symfony project: the Twilio PHP SDK so you can interact with the REST API and the FOSJsRoutingBundle to expose application URLs in JavaScript. Run the following command in your terminal:

composer require twilio/sdk friendsofsymfony/jsrouting-bundle

Make sure to follow both the installation and usage instructions for FOSJsRoutingBundle.

Creating a tunnel

We’re going to use ngrok to create a secure tunnel between the Internet and your local development environment. By doing so, Twilio can make requests and respond to your application, which is crucial for testing our implementation. Start your Symfony application with the built in PHP web-server by running the following command:

php app/console server:run

In a separate terminal, run the following command to securely expose the server to the outside world:

ngrok http 8000

You should then see output similar to the following:

Session Status                online
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4041
Forwarding                    http://xxxxxxxx.ngrok.io -> localhost:8000
Forwarding                    https://xxxxxxxx.ngrok.io -> localhost:8000

From this point onwards, you should access your application through the http://ngrok.io forwarding address so Symfony generates absolute URLs that are publicly accessible.

Creating a Twilio account

You will need a Twilio account and a phone number. If you don’t already have those, here are the instructions.
Once you’re logged in, visit the console dashboard and grab your account SID and auth token. The support article “How does Twilio’s Free Trial work?” will guide you on how to get your first Twilio phone number (make sure it has voice capabilities!)
It’s also important to mention that trial accounts have to first verify any non-Twilio number that will be receiving calls, e.g. your mobile number. When you’re ready to use Twilio in production, upgrade your account to communicate with non-verified phone numbers.

Configuring the SDK

Back in the Symfony project, add your account SID, auth token, and newly acquired Twilio phone number to app/config/parameters.yml:

parameters:
    # replace these values with your own!
    twilio_sid: ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    twilio_token: your_auth_token
    twilio_number: '+441234567890'

Next, we create a new service definition in app/config/services.yml to expose an API client that’s configured with your credentials. We’ll be using this later to make a voice call:

services:
    # ...
    twilio.client:
        class: Twilio\Rest\Client
        arguments: ['%twilio_sid%', '%twilio_token%']

Number verification with Twilio

Having a user verify their phone number will be a multi-step process. In the next few sections we shall:

  • Create a Doctrine value object to model a phone number.
  • Create a Symfony validator to correctly format and validate the phone number.
  • Create a Symfony controller & form to capture the user’s phone number.
  • Make an automated call with the Twilio API to instruct them (using text-to-speech) to input the verification code that’s displayed on-screen by using their keypad, correlating the incoming phone number with the entered digits and flagging them as verified.

Modeling a phone number

Let’s begin by creating a value object that models a user’s phone number. We use Doctrine ORM annotations to map the object (“entity”) to the underlying database so its values will be persisted.
Create a file called PhoneNumber.php in src/AppBundle/Entity and add the following code:

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Embeddable()
 */
class PhoneNumber
{
  // specify the length of generated verification codes
  const CODE_LENGTH = 6;

  /**
   * @ORM\Column(name="number", type="string", length=16, nullable=true)
   * @Assert\NotBlank()
   */
  protected $number;

  /**
   * @ORM\Column(name="country", type="string", length=2, nullable=true)
   * @Assert\NotBlank()
   */
  protected $country;

  /**
   * @ORM\Column(name="verification_code", type="string", length=PhoneNumber::CODE_LENGTH, nullable=true)
   */
  protected $verificationCode;

  /**
   * @ORM\Column(name="verified", type="boolean")
   */
  protected $verified = false;

  /**
   * @param string $number
   * @return $this
   */
  public function setNumber($number)
  {
    $this->number = $number;

    return $this;
  }

  /**
   * @return string
   */
  public function getNumber()
  {
    return $this->number;
  }

  /**
   * @param string $country
   * @return $this
   */
  public function setCountry($country)
  {
    $this->country = $country;

    return $this;
  }

  /**
   * @return string
   */
  public function getCountry()
  {
    return $this->country;
  }

  /**
   * @return $this
   */
  public function setVerificationCode()
  {
    // generate a fixed-length verification code that's zero-padded, e.g. 007828, 936504, 150222
    $this->verificationCode = sprintf('%0'.self::CODE_LENGTH.'d', mt_rand(1, str_repeat(9, self::CODE_LENGTH)));

    return $this;
  }

  /**
   * @return string
   */
  public function getVerificationCode()
  {
    return $this->verificationCode;
  }

  /**
   * @param bool $verified
   * @return $this
   */
  public function setVerified($verified)
  {
    $this->verified = $verified;

    return $this;
  }

  /**
   * @return bool
   */
  public function isVerified()
  {
    return $this->verified;
  }
}

A common trait that Symfony projects share is loading users from a database, assuming your project already has a User entity present (if not, check out this blog post on basic user handling), we can embed this value object into it using the embeddable annotation.

Add the code contained within the example class below (variable & annotation, constructor, getter/setter) to your own User class:

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Table(name="app_users")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
 */
class User implements UserInterface, \Serializable
{
  /**
   * @ORM\Embedded(class="AppBundle\Entity\PhoneNumber", columnPrefix="phone_")
   */
  protected $phoneNumber;

  // ...

  public function __construct()
  {
    $this->phoneNumber = new PhoneNumber();
  }
 /**
   * @param PhoneNumber $phoneNumber
   * @return $this
   */
  public function setPhoneNumber(PhoneNumber $phoneNumber = null)
  {
    $this->phoneNumber = $phoneNumber;

    return $this;
  }

  /**
   * @return PhoneNumber
   */
  public function getPhoneNumber()
  {
    return $this->phoneNumber;
  }
}

Validating a phone number

Before making a call, we should check that the user’s phone number is actually valid. In fact, Twilio’s REST API prefers numbers to be in the E.164 format, and we can’t rely on the user inputting their number the way we need it. Luckily, Twilio offers a lookup service that will handle both number validation and formatting for us.
Let’s implement a custom validation constraint that gets applied to the value object and formats the phone number. Create a file called E164Number.php in src/AppBundle/Validator/Constraints and add the following code:

<?php

namespace AppBundle\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class E164Number extends Constraint
{
  public $invalidNumberMessage = 'Invalid number, please check that you have entered it correctly.';

  public function getTargets()
  {
    // so we can access multiple properties
    return self::CLASS_CONSTRAINT;
  }
}

Next, create a file called E164NumberValidator.php in the same directory and add the following code:

<?php

namespace AppBundle\Validator\Constraints;

use AppBundle\Entity\PhoneNumber;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Twilio\Exceptions\RestException;
use Twilio\Rest\Client;

class E164NumberValidator extends ConstraintValidator
{
  private $twilio;

  public function __construct(Client $twilio)
  {
    $this->twilio = $twilio;
  }

  public function validate($phoneNumber, Constraint $constraint)
  {
    try {
      $number = $this->twilio->lookups
        ->phoneNumbers($phoneNumber->getNumber())
        ->fetch(['countryCode' => $phoneNumber->getCountry()]);

      $phoneNumber->setNumber($number->phoneNumber);
    } catch (RestException $e) {
      if ($e->getStatusCode() === Response::HTTP_NOT_FOUND) {
        $this->context
          ->buildViolation($constraint->invalidNumberMessage)
          ->atPath('number')
          ->addViolation();
      }
    }
  }
}

We then need to register this class in the service container and tag it so that Symfony knows it’s a validator, as well as inject the Twilio SDK client we registered previously. Add the following to app/config/services.yml:

   app.validator.e164_number:
        class: AppBundle\Validator\Constraints\E164NumberValidator
        arguments: ['@twilio.client']
        tags:
            - { name: validator.constraint_validator }

The validator’s implementation is fairly straightforward. We use the Twilio SDK client to submit the phone number to the Lookup API. If we get 404 response in return then the number isn’t valid, so we add a constraint violation.
Finally, we’ll need to update the PhoneNumber class docblock to assert our new constraint:

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use AppBundle\Validator\Constraints as AppAssert;

/**
 * @ORM\Embeddable()
 * @AppAssert\E164Number(groups={"E164"})
 * @Assert\GroupSequence({"PhoneNumber", "E164"})
 */
class PhoneNumber
{
  // ...
}

Using the @AssertGroupSequence annotation means that the prior NotBlank constraints on the number and country fields are validated beforehand, ensuring that they’ll have values when accessed by the E164Number class constraint.

Capturing the user’s phone number

Now we’ll implement a controller method that captures, validates, and saves a user’s phone number. To begin with, generate a new controller by running the following command:

php app/console generate:controller --controller=AppBundle:Phone --actions=verifyAction --route-format=annotation --template-format=twig

Next, replace the generated method with the following code:

/**
  * @Route("/verify", options={"expose"="true"}, name="verify")
  */
public function verifyAction(Request $request)
{
  $user = $this->getUser();
  $phoneNumber = $request->query->get('reset') ? new PhoneNumber() : $user->getPhoneNumber();
  $twilioNumber = $this->getParameter('twilio_number');

  $form = $this->createFormBuilder($phoneNumber)
    ->add('number', TextType::class, [
      'label' => 'Phone Number',
    ])
    ->add('country', CountryType::class, [
      'preferred_choices' => ['GB', 'US'],
    ])
    ->add('submit', SubmitType::class, [
      'label' => 'Continue',
    ])
    ->getForm();

  if ($request->isMethod('POST')) {
    $form->handleRequest($request);

    if ($form->isValid()) {
      $phoneNumber->setVerificationCode();
      $this->getUser()->setPhoneNumber($phoneNumber);
      $this->getDoctrine()->getManager()->flush();

      // TODO: call the number

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

    return $this->render('verify.html.twig', [
      'form' => $form->createView(),
    ]);
  }
}

Also update the generated verify.html.twig template so that it renders the Symfony form created in the above controller action, so the user can enter their phone number:

{% block content %}
  {% if form is defined %}
    {{ form_start(form) }}
    {{ form_widget(form) }}
    {{ form_end(form) }}
  {% endif %}
{% endblock %}

We’re almost ready to call the user. To demonstrate what we’ve achieved so far in the context of my example project, submitting a phone number will format it and save it to the user database. However, if I submit an invalid number then this is what I’d see:

Calling the user

Once the user has submitted the form, we need to kick off a phone call to them as well as display their verification code. First off we’ll address that 2nd TODO comment in the verifyAction method by making a call with Twilio’s REST API:

$this->get('twilio.client')->calls->create(
  $phoneNumber->getNumber(),
  $twilioNumber,
  ['url' => $this->generateUrl('twilio_voice_verify', [], UrlGeneratorInterface::ABSOLUTE_URL)]
);

The first two parameters are fairly self-explanatory as this is an outgoing call to the user from our Twilio number. However, once the call connects, how do we control what happens next? Enter TwiML, a markup language that defines a set of instructions to dictate call flow. If we implement a controller action that renders an initial TwiML document, passing its absolute URL as the third parameter, Twilio will know how to continue the call once the user picks up.
Important: Remember to use the ngrok URL we generated earlier when testing the Twilio integration. Otherwise Symfony will generate an absolute URL with a local hostname, which will be inaccessible.
Time to begin implementing our verification call flow. First, generate a new controller by running the following command:

php app/console generate:controller --controller=AppBundle:Twilio --actions=voiceVerifyAction --route-format=annotation --template-format=twig

Next, replace the generated class with the following code so the controller defaults to responding with XML and the action has the correct route name:

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\PhoneNumber;
use AppBundle\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twilio\Twiml;

/**
 * @Route("/twilio", defaults={"_format"="xml"})
 */
class TwilioController extends Controller
{
  const MAX_RETRIES = 3;

  private $voiceEngine = ['voice' => 'woman', 'language' => 'en'];

  /**
   * @Route("/voice/verify", name="twilio_voice_verify")
   */
  public function voiceVerifyAction(Request $request)
  {
    // TODO: generate TwiML to control call flow
  }
}

Since we’ll be building in some retry logic in case the user accidentally hits the wrong digit, let’s start off the above method’s TODO by handling what happens when they reach the maximum number of retries:

$response = new Twiml();
$retries = $request->query->get('retries', 0);

if ($retries >= self::MAX_RETRIES) {
  $response->say('Goodbye.', $this->voiceEngine);

  return new Response($response);
}

Requesting http://xxxxxxxx.ngrok.io/twilio/voice/verify?retries=3 (make sure to use your own ngrok URL) will return the following TwiML document:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say voice="woman" language="en">Goodbye.</Say>
</Response>

A voice will say goodbye and then Twilio will end the call because there are no more TwiML verbs to process. Simple enough right? Next, we’ll continue adding to our voiceVerifyAction method and instruct the user to enter their verification code:

$retryUrl = $this->generateUrl('twilio_voice_verify', ['retries' => ++$retries], UrlGeneratorInterface::ABSOLUTE_URL);

if (!$request->request->has('Digits')) {
  $gather = $response->gather(['timeout' => 5, 'numDigits' => PhoneNumber::CODE_LENGTH]);

  $gather->say(sprintf('Please enter your %d digit verification code.', PhoneNumber::CODE_LENGTH), $this->voiceEngine);
  $response->redirect($retryUrl, ['method' => 'GET']);

  return new Response($response);
}

Requesting the same URL without the query-string will return the following TwiML document:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather timeout="5" numDigits="6">
    <Say voice="woman" language="en">Please enter your 6 digit verification code.</Say>
  </Gather>
  <Redirect method="GET">http://xxxxxxxx.ngrok.io/twilio/voice/verify?retries=1</Redirect>
</Response>

This TwiML document is a bit more nuanced, so let’s break it down. The initial Gather verb will capture the first 6 digits the user enters and POST them to the current document URL (hence the Digits parameter check in the Symfony request object). We’ve nested a Say verb so that instructions will play while waiting for input. If no input is gathered after 5 seconds then Twilio will fall through to the next verb – Redirect – which will trigger a request to our controller action, but this time with an incremented retry count, continuing the call flow to once again ask for input.
Finally, we’ll try and find a user matching the recipient phone number and the verification code that was entered. If we get a match then the phone number should be flagged as verified. Otherwise we’ll inform them that their input wasn’t valid and let them try again. Continue adding to the voiceVerifyAction method:

$manager = $this->getDoctrine()->getManager();
$user = $manager->getRepository(User::class)->findOneBy([
  'phoneNumber.number' => $request->request->get('To'),
  'phoneNumber.verificationCode' => $request->request->get('Digits'),
]);

if ($user) {
  $response->say('You have been verified, goodbye.', $this->voiceEngine);
  $user->getPhoneNumber()->setVerified(true);
  $manager->flush();
} else {
  $response->say('Sorry, this code was not recognised.', $this->voiceEngine);
  $response->redirect($retryUrl, ['method' => 'GET']);
}

return new Response($response);

With our call flow completed we can now focus on displaying the user’s verification code and automatically redirecting them to another view such as their profile page (I’ll leave the route choice up to you). Add the following code to the verifyAction method body in the PhoneController class, using the commented code as guidelines:

public function verifyAction(Request $request)
{
  // $twilioNumber = …

  // used by the frontend JavaScript to poll if the user is verified yet
  if ($request->isXmlHttpRequest()) {
    return new JsonResponse([
      'verified' => $phoneNumber->isVerified(),
    ]);
  }

  // renders the view with instructions and a URL to redirect verified users
  if ($phoneNumber->getVerificationCode()) {
    return $this->render('verify.html.twig', [
      'verification_code' => $phoneNumber->getVerificationCode(),
      'redirect' => $this->generateUrl('user_profile'),
      'twilio_number' => $twilioNumber,
    ]);
  }

  // $form = ...
}

Next, let’s update the verify.html.twig template to display instructions to the user. We’ll also implement a small slice of JavaScript which polls our controller action every 5 seconds to check if the page should be redirected:

{% block content %}
  {# ... #}
  {% if verification_code is defined %}
    <p>You will receive a call from <strong>{{ twilio_number }}</strong>. When prompted, please enter the following code:</p>
    <h2>{{ verification_code }}</h2>
    <p>No phone call? <a href="{{ path('verify', {reset: true}) }}">Re-enter your phone number.</a></p>

    <script type="text/javascript">
      (function poll() {
        var timeout = 5000;

        setTimeout(function() {
          $.ajax({
            url: Routing.generate('verify'), // FOSJsRoutingBundle
            dataType: 'json',
            complete: poll,
            timeout: timeout,
            success: function (data) {
              if (data.verified) {
                window.location.replace('{{ redirect }}');
              }
            }
          });
        }, timeout);
      })();
    </script>
  {% endif %}
{% endblock %}

To demonstrate what we’ve achieved by using my example project, here’s what the user will see while they verify their phone number before being redirected:

Validating incoming Twilio requests

An important point to consider is the authenticity of data being sent to our TwiML endpoint. Twilio cryptographically signs their requests, so it’s best that we protect ourselves from any spoofed requests by a malicious third party.
Luckily, it’s very easy in Symfony to inspect the request for an entire group of controller actions before they’re invoked by implementing an event listener. Let’s begin by making an instance of Twilio’s request validator available in the service container by adding the following to app/config/services.yml:

twilio.request_validator:
    class: Twilio\Security\RequestValidator
    arguments: ['%twilio_token%']

In the event listener itself, we’ll first check for the existence of the header Twilio uses to send across the cryptographic signature. Then we call their library’s validate function, which takes the signature, request URL, and POST payload as parameters, to determine if the request is from Twilio:

<?php

namespace AppBundle\EventSubscriber;

use AppBundle\Controller\TwilioController;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Twilio\Security\RequestValidator;

class TwilioRequestListener
{
  const SIGNATURE_HEADER = 'X-Twilio-Signature';

  private $validator;

  public function __construct(RequestValidator $validator)
  {
    $this->validator = $validator;
  }

  public function onKernelController(FilterControllerEvent $event)
  {
    $controller = $event->getController();

    if (!is_array($controller)) {
      return;
    }

    if ($controller[0] instanceof TwilioController) {
      $request = $event->getRequest();
      $signature = $request->headers->get(self::SIGNATURE_HEADER);

      if (is_null($signature)) {
        throw new BadRequestHttpException(sprintf('Missing header %s', self::SIGNATURE_HEADER));
      }

      $valid = $this->validator->validate(
        $signature,
        $request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getRequestUri(),
        $request->request->all()
      );

      if (!$valid) {
        throw new BadRequestHttpException('Invalid Twilio payload');
      }
    }
  }
}

For the 2nd parameter, we must reconstruct the URL rather than rely on the Request::getUri method. This is because getUri returns a normalised value, meaning any query string parameters in the URL have been rearranged alphabetically. Because the normalised URL no longer matches the one Twilio used to compute the expected signature, our computed signature would fail validation and so the payload would be rejected.
The last thing to do is register the class in the service container and tag it so that Symfony knows it’s an event listener, as well as inject the request validator we registered above. Add the following to app/config/services.yml:

app.listener.twilio_request:
    class: AppBundle\EventSubscriber\TwilioRequestListener
    arguments: ['@twilio.request_validator']
    tags:
        - { name: kernel.event_listener, event: kernel.controller, method: onKernelController }

That’s all, folks!

That’s the end of our Twilio integration. We’ve cleanly modeled and captured a user’s phone number, implemented a verification call flow that will flag the number as verified if the verification code is entered correctly, and seamlessly redirect the verified user to a different page. As part of Twilio’s security best practices, we also implemented an event listener to verify that requests handled by our controller are originating from Twilio.
To see this integration in a real-world context, check out this example project on GitHub where I use the same verification process to grant users access to premium blog posts.

Thank you for following my tutorial. I’m a software engineer at Codevate, a custom web and mobile app development company based in Birmingham, UK. You can find my other blog posts on the Codevate software development blog or follow me on GitHub. Thanks for reading!