How to verify phone numbers with PHP, Symfony, and Twilio


♥ You can tap this article to like, comment, and share.


Attention developers! We have an open vacancy for a Senior Full-Stack Web Developer.


In this post I'm going to demonstrate how to effectively integrate Twilio in a Symfony project by implementing phone number verification. We're going to 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.

This article does assume you are looking to add Twilio features to your own Symfony project, but if you don’t have one then you can follow this quick tutorial on 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. If you're looking to start using Symfony then I'd recommend Knp University's screencast on Joyful Development with Symfony 3.

If you would like to see a real-world example of how the code written in this blog post could be utilized, please check out this example project on GitHub where I use the same verification process to grant users access to premium blog posts.

Getting started

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

  • Install the Twilio PHP SDK and another dependency using composer.
  • Create a tunnel to your local development environment using ngrok.
  • Sign up for a free Twilio account.
  • Configure the SDK's API client as a service.

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

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 need to capture the user's country code to assist Twilio in formatting the number correctly if they enter it in their national format. We also need a function to generate a verification code for the user to enter with their keypad. This class uses Doctrine ORM annotations to map the object ("entity") fields to the 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 @Assert\GroupSequence 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:

/**
  * @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('AppBundle:Phone: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:

Example invalid phone number

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(),
    ]);
  }

  // render 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 JS which polls our controller action every 5 seconds to check if the page should be redirected:

{% block content %}
  {# if form is defined ... #}
  {% 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:

Example verification code

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 }

Conclusion

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.


What did you think? Rate this article below.

We don't yet have enough ratings yet

Comments

Leave a comment

Simple markdown syntax supported: *italic*, **bold**, > quote, `code`, etc.

Subscribe to our newsletter

We don’t send many emails. You’ll just be notified of new blog articles (for now)

Let’s talk about how our software can work for you.

Get in touch