Manage a List of Safe Phone Numbers in PHP with Twilio Verify's Safe List API

September 23, 2025
Written by
Reviewed by

Manage a List of Safe Phone Numbers in PHP with Twilio Verify's Safe List API

Twilio's Verify Fraud Guard and Geo Permissions are two excellent ways Twilio helps protect your Verify Service from suspicious activity like SMS Pumping Fraud by automatically blocking risky traffic.

However, sometimes they can also block your own phone numbers, preventing you from sending messages and notifications to your customers. To solve this problem, Twilio introduced the Safe List API, which allows you to maintain a list of phone numbers that will never be blocked by Verify Fraud Guard or Geo permissions.

In short, it lets you mark phone numbers as safe to ensure they are never erroneously blocked.

In this short tutorial, you're going to learn how to integrate a Safe List into your PHP applications so that you can add approved phone numbers to, view existing numbers on, and remove phone numbers when needed.

Let's begin!

Prerequisites

To follow along with the tutorial, you only need the following:

  • PHP 8.4
  • A Twilio account (either free or paid). Create one if you don't already have one.
  • Your preferred PHP editor or IDE, such as NeoVIM or PhpStorm
  • Curl, or your network testing tool of choice
  • A little bit of command line experience

Create the core project

The first thing to do, as always, is to initialise a new PHP project directory structure and add the required dependencies.

To do that, run the commands below.

mkdir safe-list
cd safe-list
mkdir src public
composer require \
    asgrim/mini-mezzio \
    laminas/laminas-config-aggregator \
    laminas/laminas-servicemanager \
    mezzio/mezzio-fastroute \
    mezzio/mezzio-problem-details \
    twilio/sdk \
    vlucas/phpdotenv
I've formatted the third command across multiple lines for greater readability. However, if you're using Microsoft Windows, change the backspace to a caret (^) as Windows does not support breaking commands across multiple lines with backslashes.

Let's quickly go through the dependencies that were just installed:

  • laminas-config-aggregator: This package helps initialise the application's DI (Dependency Injection) container
  • laminas-servicemanager: This package is the application's DI container
  • mezzio-fastroute: This package provides the application's routing layer
  • mezzio-problem-details: This package simplifies returning standardised problem responses from requests to the application, saving us time and effort telling the user what went wrong, when something went wrong
  • Mini Mezzio: This package simplest setting up Mezzio, the framework that underpins the application.
  • PHP Dotenv: This package loads environment variables from dotenv files ( .env) making them available in PHP's $_ENV and $_SERVER superglobals
  • Twilio's PHP Helper Library: This package simplifies interacting with Twilio's APIs

Update Composer's configuration

The final thing to do before diving into the code is to update Composer's configuration. To do that, add the following to composer.json:

"scripts": {
    "serve": [
        "Composer\\Config::disableProcessTimeout",
        "php -d upload_max_filesize=5242880 -d post_max_size=7340032 -S 0.0.0.0:8080 -t public/"
    ]
}

The configuration above registers a Composer script named "serve" that makes it easier to start the application.

Register the required environment variables

Lastly, in the project's top-level directory, create a file named .env, and in that file paste the configuration below.

TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=

Next, log in to your Twilio Console, and from the Dashboard's Account Info panel, copy your Twilio Account SID and Auth Token. Then, paste them into .env as the values for TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN respectively.

The Account Info panel in the Twilio Console dashboard, showing a redacted Account SID, hidden Auth Token, and a redacted Twilio phone number.

Write the code

Now, with the core setup out of the way, let's dive in and start building the application. In the public directory, create a PHP file named index.php, then paste the code below into that file.

<?php

declare(strict_types=1);

use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\ServiceManager\ServiceManager;
use Asgrim\MiniMezzio\AppFactory;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Mezzio\Router\FastRouteRouter;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Twilio\Exceptions\TwilioException;
use Twilio\Rest\Client;

require __DIR__ . '/../vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/..");
$dotenv->load();
$dotenv
    ->required(["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"])
    ->notEmpty();

$container = new ServiceManager(new ConfigAggregator([
    \Mezzio\ConfigProvider::class,
    \Mezzio\ProblemDetails\ConfigProvider::class,
])->getMergedConfig()['dependencies']);
$container->setService(Client::class, new Client(
    $_ENV['TWILIO_ACCOUNT_SID'],
    $_ENV['TWILIO_AUTH_TOKEN'],
));
$router = new FastRouteRouter();
$app = AppFactory::create($container, $router);

$app->pipe(new RouteMiddleware($router));
$app->pipe(new DispatchMiddleware());

$app->get('/ping', fn () => new JsonResponse(time()));

$app->run();

The code above provides an extremely minimal PHP application, using the Mezzio framework. Stepping through it, the code:

  • Loads Composer's Autoloader
  • Loads the environment variables in .env
  • Initialises the application's DI container and registers a Twilio Rest client as a service in it
  • Sets up the applications routing table with a single endpoint ("/ping"), then boots the application.

Start the application and test that it works

While we've not done all that much so far, let's still test that what we've built so far works. After all, it's fun to have a win as early as possible. Start the application by running the following command.

composer serve

Then, in another terminal tab, run the following command with curl which makes a request against the "/ping" endpoint.

curl -q http://localhost:8080/ping
If you're using Linux, a BSD, or macOS, instead of opening another terminal tab or session, you could also background composer serve and run the curl request in the current terminal session.

You should see output similar to the following, printed to the terminal.

1750127333

Add a route for adding phone numbers to the Safe List

Now, let's get in and build the first of the three Safe List endpoints. We'll start with the one that allows us to add a phone number, by adding the following code in public/index.php after the definition of the "/ping" route:

$app->post('/safe-list', new readonly class(
    $container->get(Client::class),
    $container->get(ProblemDetailsResponseFactory::class)
) implements RequestHandlerInterface {
    public function __construct(
        private Client $client,
        private ProblemDetailsResponseFactory $problemDetailsResponseFactory
    ) {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $phoneNumber = $request->getParsedBody()["phone_number"];
        if (! isValidPhoneNumber($phoneNumber)) {
            return new JsonResponse(
                "The phone number to add to the Safe List must be in E.164 format.",
                400
            );
        }
        try {
            $safelist = $this->client->verify
                ->v2
                ->safelist
                ->create($phoneNumber);
        } catch (TwilioException $e) {
            return getErrorResponse(
                $this->problemDetailsResponseFactory,
                $request,
                sprintf("$phoneNumber was not added to the safe list. Reason: %s", $e->getMessage()),
                $phoneNumber,
            );
        }
        return new JsonResponse($safelist->sid);
    }
});

The code defines a route that accepts only POST requests to "/safe-list", with requests handled by an anonymous class that implements RequestHandlerInterface; specifically, the class' handle() method.

The method checks if the POST data contains an element named phone_number which contains an E.164-formatted phone number. If not, it returns a JSON response containing the message "The phone number to add to the Safe List must be in E.164 format.", along with an HTTP 400 Bad Request response code.

Otherwise, using Twilio's PHP Helper Library the code attempts to add the phone number to the Safe List. If the request fails, a Problem Details message is returned. If you're not familiar with it, it's:

…a way to carry machine-readable details of errors in a HTTP response to avoid the need to define new error response formats for HTTP APIs.

It's a bit over the top, but we're doing this to provide the user with all the applicable information on why the phone number could not be added to the Safe List in a standardised way.

If the number was successfully added, then the number's Safe List SID is returned as a JSON response.

Add in the validation functions

Now, you need to create the utility functions that were called in the previous route: isValidPhoneNumber() and getErrorResponse(). To do that, create a folder within src called App and then create a new file named utilities.php in the src/App directory. Then, in the new file, paste the code below.

<?php

declare(strict_types=1);

use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

function isValidPhoneNumber(string $phoneNumber): bool
{
    $result = preg_match("/^\+[1-9]\d{1,14}$/", $phoneNumber);
    return $result === 1;
}

function getErrorResponse(
    ProblemDetailsResponseFactory $responseFactory,
    ServerRequestInterface $request,
    string $message,
    string $phoneNumber
): ResponseInterface {
    return $responseFactory->createResponse(
        $request,
        StatusCodeInterface::STATUS_BAD_REQUEST,
        $message,
        "Bad Request",
        additional: [
            'Phone number' => $phoneNumber,
        ]
    );
}

As I mentioned, the code defines:

  • isValidPhoneNumber: uses a small regex (regular expression), ^\+[1-9]\d{1,14}$, to validate the supplied phone number.
  • getErrorResponse: creates and returns a Problem Details message, with an HTTP 400 Bad Request status and the supplied message string.

isValidPhoneNumber() is fine for simplistic phone number validation. But for a more robust solution, you could use laminas-phone-number-validator. This package uses Twilio's Lookup API, validating phone numbers based on data from communications providers.

Update Composer's autoload configuration to load the validation functions Without a change to composer.json, src/App/utilities.php will not be loaded as part of Composer's autoload configuration. So, to ensure that it is, add the following to composer.json:

"autoload": {
    "files": [
        "src/App/utilities.php"
    ]
}

Now, run the following command to complete the process:

​​composer dump-autoload

Add a route for viewing phone numbers on the Safe List

Next, let's add the ability to check if a phone number is already on the Safe List. Do this by adding the following code in public/index.php, after the route that you just added.

$app->get('/safe-list/{phoneNumber:\+\d+}', new readonly class(
    $container->get(Client::class),
    $container->get(ProblemDetailsResponseFactory::class)
) implements RequestHandlerInterface {
    public function __construct(
        private Client $client,
        private ProblemDetailsResponseFactory $problemDetailsResponseFactory
    ) {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $phoneNumber = $request->getAttribute("phoneNumber");
        if (! isValidPhoneNumber($phoneNumber)) {
            $message = "The phone number to delete must be in E.164 format.";
            return getErrorResponse(
                $this->problemDetailsResponseFactory,
                $request,
                $message,
                $phoneNumber
            );
        }

        try {
            $safelist = $this->client->verify
                ->v2
                ->safelist($phoneNumber)
                ->fetch();
        } catch (TwilioException $e) {
            $message = (str_ends_with($e->getMessage(), "$phoneNumber was not found"))
                ? "$phoneNumber is not on the safe list."
                : $e->getMessage();
            return getErrorResponse(
                $this->problemDetailsResponseFactory,
                $request,
                $message,
                $phoneNumber
            );
        }
        return new JsonResponse("Phone number is on the safe list. SID: $safelist->sid");
    }
});

This route accepts GET requests to the "/safe-list" endpoint with the phone number in E.164 format appended. Similar to the previous route, it checks if the phone number is both present and formatted correctly. If not, it returns a Problem Details message containing the message: "The phone number to delete must be in E.164 format.".

Otherwise, it makes a request to the Safe List API to check if the provided number is in the Safe List. If not, it returns an applicable Problem Details response. If it is, it returns the message "Phone number is on the safe list." as a JSON response, along with the phone number's Safe List SID.

Add a route for deleting phone numbers from the Safe List

Finally, let's add the ability to remove a phone number from the Safe List, by adding the following code to public/index.php after the previous route definition.

$app->delete('/safe-list/{phoneNumber:\+\d+}', new readonly class(
    $container->get(Client::class),
    $container->get(ProblemDetailsResponseFactory::class)
) implements RequestHandlerInterface {
    public function __construct(
        private Client $client,
        private ProblemDetailsResponseFactory $problemDetailsResponseFactory
    ) {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $phoneNumber = $request->getAttribute("phoneNumber");
        if (! isValidPhoneNumber($phoneNumber)) {
            return new JsonResponse("The phone number to delete must be in E.164 format.", 400);
        }

        try {
            $deleted = $this->client->verify
                ->v2
                ->safelist($phoneNumber)
                ->delete();
            if ($deleted) {
                return new JsonResponse("$phoneNumber was removed from the safe list.");
            }
        } catch (TwilioException $e) {
            return getErrorResponse(
                $this->problemDetailsResponseFactory,
                $request,
                sprintf("$phoneNumber was not removed from the safe list. Reason: %s", $e->getMessage()),
                $phoneNumber,
            );
        }
    }
});

Test that the application works as expected

Now that the application's complete, it's time to test that it works.

Add a phone number to the Safe List

Start by adding your mobile/cell phone number to the Safe List by replacing <Your Phone Number> with your phone number — in E.164 format — in the command below, then running it in a new terminal tab.

curl --header "Accept: application/json" \
    --data-urlencode "phone_number=<Your Phone Number>" \
    http://localhost:8080/safe-list

On success, the Safe List SID of the phone number will be printed to the terminal. If the command fails, however, such as because the phone number is not in E.164 format, you will see the following message printed to the terminal:

"The phone number to add to the Safe List must be in E.164 format."

Check that the phone number is on the Safe List

Now that the phone number is on the Safe List, it's time to be doubly-sure, by checking. To do that, in the command below replace <Your Phone Number> with your E.164-formatted phone number, then run it:

curl -q --header "Accept: application/json" \
    "http://localhost:8080/safe-list/<Your Phone Number>"

You should see the following message printed to the terminal, confirming that your phone number is on the Safe List:

"Phone number is on the safe list. SID: GNe24f962df08746a019fb2e2f1c762332"

Alternatively, you could try adding your phone number to the Safe List again. You should see terminal output (formatted for readability) similar to the following, confirming that it is already on the list:

{
    "Phone number": "+61123456789",
    "title": "Bad Request",
    "type": "https://httpstatus.es/400",
    "status": 400,
    "detail": "+61123456789 was not added to the safe list. Reason: [HTTP 400] Unable to create record: Phone number or phone number prefix already exists in safe list"
}

Delete the phone number from the Safe List

Finally, let's remove your phone number from the Safe List. To do that, in the command below replace <Your Phone Number> with your E.164-formatted phone number, then run it:

curl -q --header "Accept: application/json" \
    -X DELETE \
    "http://localhost:8080/safe-list/<Your Phone Number>"

On success, you will see a message similar to the following, printed to the terminal:

"+61123456789 was removed from the safe list."

That's how to manage a list of safe phone numbers in PHP with Twilio Verify's Safe List API

Now you know how to maintain a list of phone numbers that will never be blocked by Verify Fraud Guard and Geo permissions, by using Twilio's Safe List, while still avoiding suspicious messages from being sent by your Verify Service.

I hope that you see the value and make use of it in your PHP applications to help ensure that they're as secure as possible.

Happy building!

Matthew Setter is (primarily) the PHP, Go, and Rust editor on the Twilio Voices team. He’s also the author of Mezzio Essentials and Deploy with Docker Compose. You can find him at msetter[at]twilio.com. He's also on LinkedIn and GitHub.

Phone book icons created by Freepik on Flaticon.