Build a PHP Phone Number Validator Using Twilio and laminas-validator

August 02, 2021
Written by
Reviewed by

Build a PHP Phone Number Validator Using Twilio and laminas-validator

No matter where your data comes from — a user, database, an environment variable, or somewhere else entirely — it must be validated. If not, how can you be sure that it's valid?

This is such an important and accepted part of modern software development that all of the major PHP frameworks, such as Laravel, Symfony, and Mezzio provide a validation component. If you're not using a framework, Composer can quickly integrate a third-party library such as laminas-validator.

However, what if a validation library or a framework's validation component doesn't support a validator that matches your use case? In that case, it's time to write a custom validator.

In this tutorial, I'm going to show you how to write a custom phone number validator for laminas-validator using Twilio's Lookup API.

Tutorial Requirements

To follow this tutorial you need the following components:

Why write a custom phone number validator?

In my fictitious small business, The Little PHP Shop, customers can register for an account using an email address and phone number. laminas-validator already supports email address validation, but it doesn't support phone number validation. Given that, we need to write a custom validator.

We're not verifying if a phone number is in the possession of a given person, active, and so on. We're only validating if a given string forms a valid phone number.

 

Validating phone numbers isn't an easy task, partly because there are just so many ways to write valid ones. For example, in the list below you can see various combinations of valid German phone numbers.

  • +49 123 4567 1234
  • +49 123 45671234
  • +49 (0)123 45671234
  • +49 12345671234
  • +4912345671234
  • 004912345671234
  • 0123 4567 1234
  • 0123 45671234
  • 012345671234

Bearing in mind that this is just one country, I hope you see that it could be quite complex to code up a solution to validate phone numbers from every country in the world covering all of their possible permutations.

So to avoid that path of rabid complexity, in this tutorial I'm going to show you how to use Twilio's Lookup API to implement phone number validation in a comparatively simple way.

Before I describe how if you're not familiar with the Lookup API, here's a quick overview:

The Lookup API provides a way to retrieve additional information about a phone number.

The API returns two things if a phone number is valid:

  • An HTTP 200 status code, showing that the request succeeded.
  • A JSON payload containing a range of details about the number. It includes the carrier, phone number type, and phone number in multiple formats, which you can see below.
{
    "caller_name": null,
    "carrier": {
        "error_code": null,
        "mobile_country_code": "310",
        "mobile_network_code": "456",
        "name": "verizon",
        "type": "mobile"
  },
    "country_code": "US",
    "national_format": "(510) 867-5310",
    "phone_number": "+15108675310",
    "add_ons": null,
    "url": "https://lookups.twilio.com/v1/PhoneNumbers/+15108675310"
}

If the number isn't valid, then an HTTP 404 status code is returned, along with a JSON response body with applicable details as to why and what to do.

{
    "code": 20404,
    "message": "The requested resource /PhoneNumbers/+1 was not found",
    "more_info": "https://www.twilio.com/docs/errors/20404",
    "status": 404
}

To keep the validator somewhat simplistic, we're only going to validate phone numbers in two formats, national: "(510) 867-5310" and international: "+15108675310".

In short, how the validator will then work is like this:

  • It will make a request to the Lookup API
  • If the request returns phone number details, then we use that as confirmation that the phone number exists and is therefore valid.
  • If it returns an error, then it's safe to say that the phone number does not exist, and is not valid.

To simplify interacting with the Lookup API, we're going to use Twilio's PHP helper library. Let's begin!

Setup the project directory

The first thing we need to do is to create the project directory structure and then change into the new directory. To do that, run the commands below. If you are using a Unix or macOS computer:

mkdir -p twilio-phone-number-validator/src/Twilio
cd twilio-phone-number-validator

If you're using Microsoft Windows, use the commands below instead.

mkdir twilio-phone-number-validator/src/Twilio
cd twilio-phone-number-validator

Write the code

Create a Composer configuration

Next, in the project's root directory, create a new file named composer.json, and paste the code below into it.

{
    "require": {
        "php": "^7.4 || ~8.0.0"
    },
    "autoload": {
        "psr-4": {
            "Settermjd\\Validator\\": "src/"
        }
    }
}

The configuration sets up a PSR-4 autoloaded namespace for our code Settermjd\Validator which points to src, and the required versions of PHP.

Install the required dependencies

Next, we need to install the project's dependencies:

Run the command below, in the root directory of the project, to install them. We could have added them to composer.json in the previous step, but using composer require will ensure that the latest versions are installed—no matter when you follow this tutorial.

composer require --sort-packages \
    laminas/laminas-validator \
    twilio/sdk \
    vlucas/phpdotenv

Retrieve your Twilio credentials

Next, you need to retrieve your Twilio credentials so that the validator can make authenticated requests to Twilio's Lookup API. You will store these credentials in a new file named .env. Create the file in the root directory of the project, then paste the code below into it.

TWILIO_AUTH_SID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Next, from the Twilio Console's Dashboard, copy your "Account SID" and "Auth Token" and paste them in place of the respective placeholder values in .env.

Retrieve your Twilio auth token and account SID

Create the phone number validator

Next, create a new file named PhoneNumber.php in src/Twilio/ and copy the code from GitHub into it. Then, let's step through it.

<?php

declare(strict_types=1);

namespace Settermjd\Validator\Twilio;

use Laminas\Stdlib\ArrayUtils;
use Laminas\Validator\AbstractValidator;
use Traversable;
use Twilio\Exceptions\TwilioException;
use Twilio\Rest\Client;

It starts by setting the namespace and importing the required classes.

class PhoneNumber extends AbstractValidator
{
    public const PHONE_NUMBER_INTL = 'phone_number_intl';
    public const PHONE_NUMBER_NTL = 'phone_number_ntl';

    protected array $messageTemplates = [
        self::PHONE_NUMBER_INTL => "'%value%' is not a valid phone number in E.164 format.",
        self::PHONE_NUMBER_NTL => "'%value%' is not a valid nationally formatted phone number.",
    ];

After that, it defines a new class, PhoneNumber which extends Laminas\Validator\AbstractValidator. We're doing this as AbstractValidator greatly simplifies implementing a custom laminas validator.

PhoneNumber then defines two constants and an array named $messageTemplates. The array defines message templates for when a phone number cannot be validated, letting us return a different message based on whether the phone number being validated is in national or international format.

    private Client $client;
    private string $countryCode = '';

    public function __construct($options = null)
    {
        parent::__construct($options);

        if ($options instanceof Traversable) {
            $options = ArrayUtils::iteratorToArray($options);
        }

        if (array_key_exists('client', $options)) {
            $this->client = $options['client'];
        }

        if (array_key_exists('countryCode', $options) && $options['countryCode'] !== '') {
            $this->countryCode = $options['countryCode'];
        }
    }

It then defines two private class member variables:

  • $client: This will store the Twilio Client object which we'll use to make requests to Twilio's Lookup API.
  • $countryCode: This, optionally, stores the phone number's country code, which is required if the number is in national format.

After that, it defines the class' constructor. The method takes one parameter, an array named $options, which contains one or more initialization options. At most, the constructor will make use of two: an initialized Client object and a country code. These are used to initialize the applicable class member variables if present.

public function isValid($value)
{
    if ($value === '') {
        return false;
    }

    $this->setValue($value);

Next, it defines the isValid method, required by AbstractValidator, where we define the validation logic. The role of the method is to return true if the value passed to it validates, and false otherwise.

Here's how it will work. It takes one argument, $value, which is a string containing the phone number to validate. If the phone number is empty, the code returns false immediately. It does this as an empty phone number, whether national or international, would always return an HTTP 404. Given that, there's no point wasting the user's time by making a request to Twilio that is bound to fail.

    try {
        $fetchOptions = ["type" => ["carrier"]];
        if ($this->countryCode !== '') {
            $fetchOptions['countryCode'] = $this->countryCode;
        }

If the phone number isn't empty, it initializes an array, $fetchOptions, based on the number format being validated.

In the table below, you can see how it needs to be composed, based on the phone number's format.

International Number

National Number

[

    "type" => ["carrier"],

]

[

    "type" => ["carrier"],

    "countryCode" => "US",

]

 

I've deliberately skipped over many options that the API supports, as they weren't relevant in this simplistic example.

                $this->client
                ->lookups
                ->v1
                ->phoneNumbers($value)
                ->fetch($fetchOptions);
            return true;
        } catch (TwilioException $e) {
            if ($this->countryCode === '') {
                $this->error(self::PHONE_NUMBER_INTL);
            } else {
                $this->error(self::PHONE_NUMBER_NTL);
            }
            return false;
        }
    }
}

Finally, a request is made to Twilio's Lookup API wrapped in a try/catch block. This is because if the request returns an HTTP 404, then a TwilioException is thrown. If one is thrown, then we know that the number isn't valid. Here, we set the appropriate error message, based on whether the country code was set and return false. However, if the request succeeds, true is returned.

Before we test the code, there's one final thing I want to cover. As the request returns phone number information in the response, it might seem wasteful to throw that information away. However, using the Lookup API can be far less work than creating a regular expression capable of validating every possible phone number yourself. Plus, the Lookup API doesn't have a dedicated validation endpoint.

This is especially the case when you consider that requests for carrier and caller name information aren't free; Carrier information costs $0.005 per phone number looked up. Caller name information (currently available only in the US) costs $0.01 per phone number looked up.

Test the code

Now that the code's written, it's time to test that it works. Create a new file, named index.php in the root directory of the project. In it, paste the code below.

<?php

use Settermjd\Validator\Twilio\PhoneNumber;
use Twilio\Rest\Client;

require_once 'vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

$sid = $_ENV['TWILIO_ACCOUNT_SID'];
$token = $_ENV['TWILIO_AUTH_TOKEN'];
$client = new Client($sid, $token);
$validator = new PhoneNumber([
    'client' => $client,
]);

print $validator->isValid('xxxxxxxxxxxxxx')
    ? 'Is valid.'
    : 'Is not valid';

Replace the phone number placeholder, 'xxxxxxxxxxxxxx', with a valid international phone number, such as "+14151231234". Then, in the terminal, run the command below to test that the validator works. If successful, you should see "Is valid." printed to the terminal.

php index.php

Now, let's test it using a valid national number. First, replace the phone number you used with a phone number in national format, such as "0417111111".

Secondly, add a second key/value pair to the array passed to PhoneNumber's constructor, where the key's name is countryCode and its value is the two-letter country code of the phone number you're validating, so that it resembles the example below.

$validator = new PhoneNumber([
    'client' => $client,
    'countryCode' => 'AU',
]);

With the changes made, run the command below to validate the number.

php index.php

If you've entered a valid combination of phone number and country code, then you should see "Is valid." printed to the terminal.

Finally, let's test the validator using an invalid phone number. Replace the phone number you used with "+1" and run the command above, again. This time, you should see "Is not valid." printed to the terminal.

You can remove the countryCode element from the array passed to PhoneNumber's constructor, or leave it if you prefer.

That's how to build a PHP phone number validator using Twilio

There's a lot more functionality that we could have tapped into. However, our custom validator does what we need. If the available laminas validators don't quite do what you need, why not have a go at writing a custom one yourself.

I'd love to see what you build!

Matthew Setter is a PHP Editor in the Twilio Voices team and a polyglot developer. He’s also the author of Mezzio Essentials and Docker Essentials. When he’s not writing PHP code, he’s editing great PHP articles here at Twilio. You can find him at msetter@twilio.com; he's also settermjd on Twitter and GitHub.