How to Secure Image Uploads in PHP with Twilio Verify

July 21, 2025
Written by
Reviewed by

How to Secure Image Uploads in PHP with Twilio Verify

While a username and password is still a valid way to authenticate users and secure image uploads — it's not the complete solution. Modern web applications have to do more to ensure only valid users are allowed to use them, such as implementing 2FA (Two-factor authentication), which includes One-time Passwords (OTP) and magic links.

If you want to learn how to do this, then this tutorial is for you! You'll learn how to build a small, PHP-based image upload application that generates one-time pass codes delivered to users via SMS, and validates them using Twilio's Verify API.

After successfully authenticating themselves, users will be able to upload JPEG and PNG images, storing them on the local filesystem.

If Python is your preferred language, check out this tutorial that shows how to build an image upload app with Flask and Twilio Verify.

Prerequisites

To follow along with this tutorial, you're going to need the following:

  • PHP 8.4
  • Some prior experience with Twig would be ideal, but is not essential
  • Composer installed globally
  • A Twilio account (free or paid). Click here to create one if you don't have one already.
  • Your preferred web browser
  • A mobile/cell phone that can receive SMS

About the application

While you already have a broad understanding of the application, let's dive into a bit more depth before we start building it.

The application has three routes:

The "login" route

This is where the user will submit their username as the first part in the authentication process. If the username is not in the application's user list, they're redirected back to the "login" route. Otherwise, they're sent a one-time passcode by SMS and redirected to the "verify" route.

The "verify" route

This is where the user will enter and submit the one-time pass code that they received. If the code is valid, they'll be marked as logged in and redirected to the "upload" route. Otherwise, they'll be redirected back to the "verify" route to try again.

The "upload" route

This is where the user can upload JPEG and PNG images.

Bootstrap the application

Let's start building!

Create the project's core directory structure and change into the project's top-level directory, by running the commands below, wherever you keep your PHP projects.

mkdir verify-protected-image-uploader
cd verify-protected-image-uploader
mkdir -p public/{css,images} \
    data/uploads \
    src/App \
    templates/{app,error,layout}

If you're using Microsoft Windows, run the commands below instead of the final command above.

md verify-protected-image-uploader
chdir verify-protected-image-uploader
md public public\css public\images data\uploads src src\App templates templates\app templates\error templates\layout

Here's what the directory structure looks like:

.
├── data
│   └── uploads
├── public
│   ├── css
│   └── images
├── src
│   └── App
└── templates
    ├── app
    ├── error
    └── layout

The commands created a new directory named verify-protected-image-uploader and created several sub-directories. These are:

  • public: This stores the application's bootstrap file ( index.php)
  • public/css: This stores the application's stylesheet
  • public/images: This stores the application's static image assets
  • data/uploads: This stores the uploaded JPEG and PNG images
  • src/App: This stores the application's source files organised in a PSR-4-compliant structure
  • templates: This stores the view templates for the three routes, separated into three subdirectories: app, error, and layout; only app and layout are used, but all three are required. The application uses the Twig templating engine, which supports template inheritance where child templates can extend parent templates. This will be familiar if you're aware of the two-step view pattern. The parent template is stored in the layout directory. The route-specific (child) templates are stored in the app directory.

Add the required dependencies

The next thing to do is to install the required PHP packages. These are:

To install them, run the command below.

composer require \
    asgrim/mini-mezzio \
    laminas/laminas-config-aggregator \
    laminas/laminas-servicemanager \
    mezzio/mezzio-fastroute \
    mezzio/mezzio-flash \
    mezzio/mezzio-session \
    mezzio/mezzio-session-ext \
    mezzio/mezzio-twigrenderer \
    twilio/sdk \
    vlucas/phpdotenv
If you're using Microsoft Windows, use a caret (^) instead of a backslash, as Windows doesn't support backslashes in terminal commands.

Autoload the PHP source files

Next, add the following to composer.json:

"autoload": {
    "psr-4": {
        "App\\": "src/App"
    }
}

This adds a custom PSR-4 namespace named "App" to Composer's autoloader, under which all of the PHP source files in the src/App directory will be added. Sure, the namespace's name is rather boring…but it works.

Add a Composer script to simplify running the app

While you're editing composer.json, add the following configuration after the autoload configuration.

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

This adds a Composer script named "serve" that:

Feel free to adjust the maximum file and POST size values, should you want to upload very large files.

Set up the application's bootloader

The next step is to create the application's bootloader. In the public directory create a new file named index.php, and in that file, paste the code below.

<?php

declare(strict_types=1);

use Laminas\Diactoros\Response\TextResponse;
use Laminas\ServiceManager\ServiceManager;
use Asgrim\MiniMezzio\AppFactory;
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;

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

$container = new ServiceManager();
$router = new FastRouteRouter();
$app = AppFactory::create($container, $router);
$app->pipe(new RouteMiddleware($router));
$app->pipe(new DispatchMiddleware());
$app->get('/login', new class implements RequestHandlerInterface {
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return new TextResponse('Login');
    }
});
$app->get('/verify', new class implements RequestHandlerInterface {
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return new TextResponse('Verify');
    }
});
$app->get('/upload', new class implements RequestHandlerInterface {
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return new TextResponse('Upload');
    }
});

$app->run();

The code is far from complete, yet contains the file's basic structure. It initialises a new (Mini Mezzio) application with an empty DI (Dependency Injection) container (ready for services to be registered), and three routes: one for login, code verification, and file upload.

You'll build on this foundation shortly. But for now, start the application by running the following command:

composer serve

Then, check that it works by opening one (or all) of http://localhost:8080/login, http://localhost:8080/verify, and http://localhost:8080/upload. You should see the respective route's name printed to the browser, as in the screenshot below.

The login route of the application, completely unstyled.

What you've built is a good first step, but it's hardly the application that I promised you at the top of the tutorial. So, let's iterate on it until you get there.

Import the required classes

First things first, update the imports list at the top of public/index.php to match the following list:

use App\Handler\LoginHandler;
use App\Handler\UploadHandler;
use App\Handler\VerifyHandler;
use App\Service\TwilioVerificationService;
use Asgrim\MiniMezzio\AppFactory;
use Dotenv\Dotenv;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\TextResponse;
use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory;
use Laminas\ServiceManager\ServiceManager;
use Mezzio\Flash\ConfigProvider as MezzioFlashConfigProvider;
use Mezzio\Flash\FlashMessageMiddleware;
use Mezzio\Router\FastRouteRouter;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Mezzio\Router\RouterInterface;
use Mezzio\Session\ConfigProvider as MezzioSessionConfigProvider;
use Mezzio\Session\Ext\ConfigProvider as MezzioSessionExtConfigProvider;
use Mezzio\Session\SessionMiddleware;
use Mezzio\Template\TemplateRendererInterface;
use Mezzio\Twig\ConfigProvider as MezzioTwigConfigProvider;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Twilio\Rest\Client;

There's not much to report here. You've just imported all of the classes that you'll need, which I'll progressively cover throughout the remainder of the tutorial.

Load the required environment variables

Next, in public/index.php add the following after requiring Composer's autoloader:

$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
$dotenv->required([
    'TWILIO_ACCOUNT_SID',
    'TWILIO_AUTH_TOKEN',
    'UPLOAD_DIRECTORY',
    'VERIFY_SERVICE_SID',
    'YOUR_PHONE_NUMBER',
    'YOUR_USERNAME',
])->notEmpty();

The code uses PHP Dotenv to load environment variables from .env in the project's top-level directory into PHP's $_ENV and $_SERVER superglobals. This helps us keep the application's configuration out of the code and deploy the application more easily across different environments. It also mandates that six environment variables, TWILIO_ACCOUNT_SID etc., are defined and not empty.

As you've not created .env yet, create it in the project's top-level directory and paste the following into the file:

TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
VERIFY_SERVICE_SID=
UPLOAD_DIRECTORY=data/uploads/
YOUR_USERNAME=user@example.org
YOUR_PHONE_NUMBER="<YOUR_PHONE_NUMBER>"

TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN store your Twilio authentication credentials, which are required to make authenticated requests to Twilio's APIs.

I appreciate that storing them in a plain text file is not ideal, and that a secrets manager such as HashiCorp Vault is the right place. However, purely for the sake of simplicity, you'll store them as environment variables.
The Account Info panel of the Twilio Console Dashboard

To retrieve them, log into your Twilio Console dashboard. Then, in the Account Info panel, which you can find at the bottom of the main page, copy your Account SID and Auth Token. Paste them into .env as the values for TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN, respectively.

Next, comes the VERIFY_SERVICE_SID, which is the unique ID the app will use to interact with Twilio Verify. To retrieve this, you first need to create a Verify service.

The Verify Services section of the Twilio Console

To do that, back in the Twilio Console, navigate to Explore products > Verify > Services. There, click Create new.

The Create New Verify Service form in the Twilio Console with a Friendly name supplied and the Verification channel set to SMS

In the Create new (Verify Service) form that appears, provide a Friendly name, enable the SMS verification channel, and click Continue.

The Enable Fraud Guard section of the Create New Verify Service process in the Twilio Console with "Enable Fraud Guard" set to "Yes"

Following that, click Continue in the Enable Fraud Guard stage.

The settings form for a Verify Service. The General tab is active and the Friendly name and Service's SID are both visible

Now, you'll be on the Service settings page for your new Verify Service. Copy the Service SID and set it as the value of VERIFY_SERVICE_SID in .env.

Finally, replace <YOUR_PHONE_NUMBER> with your mobile/cell phone number — but leave the default values for UPLOAD_DIRECTORY and YOUR_USERNAME as they are.

Wire up the DI container

Next up, you need to register all of the required services with the application's DI container. To do that, find the following line in public/index.php:

$container = new ServiceManager();

Then, replace it with the following:

$config                                       = new ConfigAggregator([
    MezzioTwigConfigProvider::class,
    MezzioSessionConfigProvider::class,
    MezzioSessionExtConfigProvider::class,
    MezzioFlashConfigProvider::class,
    new class ()
    {
        public function __invoke(): array
        {
            return [
                'templates' => [
                    'paths' => [
                        'app'    => [__DIR__ . '/../templates/app'],
                        'error'  => [__DIR__ . '/../templates/error'],
                        'layout' => [__DIR__ . '/../templates/layout'],
                    ],
                ],
            ];
        }
    },
])->getMergedConfig();

$dependencies                                 = $config['dependencies'];
$dependencies['services']['config']           = $config;
$dependencies['services']['config']['upload'] = [
    'upload_dir' => __DIR__ . '/../' . $_SERVER['UPLOAD_DIRECTORY'],
];
$dependencies['services']['config']['users']  = [
    $_SERVER['YOUR_USERNAME'] => $_SERVER['YOUR_PHONE_NUMBER'],
];

$container                                    = new ServiceManager($dependencies);
$container->addAbstractFactory(new ReflectionBasedAbstractFactory());
$container->setFactory(RouterInterface::class, fn() => new FastRouteRouter());
$container->setFactory(TwilioVerificationService::class, new class {
    public function __invoke(ContainerInterface $container): TwilioVerificationService
    {
        $client = new Client(
            $_ENV['TWILIO_ACCOUNT_SID'],
            $_ENV['TWILIO_AUTH_TOKEN'],
        );
        return new TwilioVerificationService($client, $_ENV['VERIFY_SERVICE_SID']);
    }
});
$container->setFactory(LoginHandler::class, new class {
    public function __invoke(ContainerInterface $container): LoginHandler
    {
        return new LoginHandler(
            $container->get(TemplateRendererInterface::class),
            $container->get(TwilioVerificationService::class),
            $container->get('config')['users'],
        );
    }
});
$container->setFactory(VerifyHandler::class, new class {
    public function __invoke(ContainerInterface $container): VerifyHandler
    {
        return new VerifyHandler(
            $container->get(TemplateRendererInterface::class),
            $container->get(TwilioVerificationService::class),
            $container->get('config')['users'],
        );
    }
});
$container->setFactory(UploadHandler::class, new class {
    public function __invoke(ContainerInterface $container): UploadHandler
    {
        return new UploadHandler(
            $container->get(TemplateRendererInterface::class),
            $container->get('config')['upload'],
        );
    }
});

This (rather long) change registers services for:

  • Mezzio Flash : so that the app can set and retrieve flash messages to notify the user after an action takes place
  • Mezzio Session : to simplify interacting with the current session
  • Mezzio Twig Renderer : as the app uses the Twig template engine it simplifies rendering the application's user-facing view layer; the anonymous class registers the template paths, so that Twig knows where to find the required template files.
  • The application's configuration, containing the application's user list and upload directory, using the values defined in .env, earlier
  • A custom Twilio verification service class that simplifies sending the verification code to the user and validating it
  • The handler classes for each of the three routes; LoginHandler, VerifyHandler, and UploadHandler, respectively

Update the application's configuration

Now, there's one final update to make in public/index.php. That's to update how the Mini Mezzio application is instantiated. To do that, replace the following code:

$app = AppFactory::create($container, $router);
// .. existing code
$app->run();

With the following:

$app = AppFactory::create($container, $container->get(RouterInterface::class));
$app->pipe(RouteMiddleware::class);
$app->pipe(SessionMiddleware::class);
$app->pipe(FlashMessageMiddleware::class);
$app->pipe(DispatchMiddleware::class);
$app->route('/login', LoginHandler::class, ['GET', 'POST'], 'login');
$app->route('/verify', VerifyHandler::class, ['GET', 'POST'], 'verify');
$app->route('/upload', UploadHandler::class, ['GET', 'POST'], 'upload');
$app->run();

This change adds session and flash message support to every route, and uses dedicated handler classes to handle requests to the three routes, instead of anonymous classes.

Define the route handler classes

Now that the bootstrap file has been refactored, you need to create the three handler classes.

Define the login route's handler

You'll start with the "login" route. Create a new directory named Handler in src/App. In that directory, create a file named LoginHandler.php. Then, in the file, paste the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use App\Service\TwilioVerificationService;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use Mezzio\Session\SessionMiddleware;
use Mezzio\Template\TemplateRendererInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function array_key_exists;
use function array_keys;
use function in_array;

final readonly class LoginHandler implements RequestHandlerInterface
{
    use FlashMessagesTrait;

    public function __construct(
        private TemplateRendererInterface $renderer,
        private TwilioVerificationService $twilioRestService,
        private array $users = [],
    ) {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $session = $request->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE);
        if ($request->getMethod() === 'GET') {
            $data         = [];
            $errorMessage = $this->getFlash($request, 'error');
            if ($errorMessage !== null) {
                $data['error'] = $errorMessage;
            }
            if ($session->has('username')) {
                $session->unset('username');
            }
            return new HtmlResponse($this->renderer->render('app::login', $data));
        }

        $formData = $request->getParsedBody();
        if (
            ! array_key_exists('username', $formData)
            || $formData['username'] === ''
            || $this->users === []
            || ! in_array($formData['username'], array_keys($this->users))
        ) {
            return new RedirectResponse('/login');
        }

        $verificationInstance = $this->twilioRestService->sendVerificationCode($this->users[$formData['username']]);
        if ($verificationInstance->status === 'pending') {
            $session->set('username', $formData['username']);
            return new RedirectResponse('/verify');
        }
    }
}

LoginHandler is instantiated with three parameters:

  • A TemplateRendererInterace instance. This provides access to the application's templating layer which is, effectively, a wrapper around the Twig templating engine.
  • A TwilioVerificationService instance. It uses this to send the verification code to the user
  • An array of the application's users (admittedly, only one)

The handle() method starts by retrieving a SessionInterface object from the request. This provides easy access to PHP's session handling functionality. It then checks if the route was requested with the GET method. If so, before rendering the application's login template ( templates/app/login.html.twig, which you'll create shortly), it:

  • Checks if an error message has been set as a flash message. If so, it's stored as a template variable
  • Removes the username from the session if it's been set

The latter part of the handle() method is run if the login route was requested as a POST request. When so, it first retrieves the POST data from the request, then checks if it contains a username that is in the application's user list.

In a production application, make sure you perform more thorough validation (filtering and sanitising) of all input using packages such as laminas-inputfilter or Symfony's Validator component. Also, check out OWASP's Cheat Sheet Series for a full range of guides on hardening your PHP applications.

If a valid user is not found, the user is redirected back to the "login" route. If a valid username is available, the user is sent a verification code via SMS using the TwilioVerificationService ($this->twilioRestService). If the SMS was sent (or is in the process of being sent), their username is set in the current session, then they're redirected to the "verify" route.

The code only checks if the SMS' status is set to "pending". It can also be set to "approved", "canceled", "max_attempts_reached", "deleted", "failed", and "expired". But, for the sake of simplicity, you're not doing that. However, feel free to experiment with these values.

Define a trait to set and retrieve flash messages

The FlashMessagesTrait is imported at the top of the LoginHandler. This trait defines two methods:

  • setFlash() to set flash messages
  • getFlash() to retrieve set flash messages

As each of the handlers require one or both of these methods, I wrapped them in a trait that can be shared between each handler, instead of embedding it in a base class that each handler class must extend.

Let's create the trait by creating a new file named FlashMessagesTrait.php in src/App/Handler. Then, in the file paste the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use Psr\Http\Message\ServerRequestInterface;

trait FlashMessagesTrait
{
    public function setFlash(ServerRequestInterface $request, string $key, string $message): void
    {
        $flashMessage = $request->getAttribute('flash');
        $flashMessage?->flash($key, $message);
    }

    public function getFlash(ServerRequestInterface $request, string $key): string|null
    {
        $flashMessage = $request->getAttribute('flash');
        return $flashMessage?->getFlash($key) ?? null;
    }
}

setFlash() retrieves a FlashMessagesInterface object from the current request and sets a new flash message (or "flashes" a message) with the provided key ($key) set to the provided value ($value). FlashMessagesInterface objects provide a universal, and simple, interface to Mezzio Flash, to set and retrieve flash messages.

If you're not familiar with the term "flash message", quoting PHP Tutorial:

A flash message allows you to create a message on one page and display it once on another page. To transfer a message from one page to another, you use the $_SESSION superglobal variable.
Mezzio Flash takes this functionality one step further, allowing you to create a message and display it within the same request. You've not used this functionality in the app, as it's not necessary.

Define a service to send and verify validation codes

To finish building the LoginHandler, you need to implement the TwilioVerificationService which LoginHandler uses to send a verification code to the user and to validate verification codes which users have received.

To do that, in src/App create a directory named Service, and in that directory create a file named TwilioVerificationService.php. Then, in that file, paste the code below.

<?php

declare(strict_types=1);

namespace App\Service;

use Twilio\Rest\Client;
use Twilio\Rest\Verify\V2\Service\VerificationCheckInstance;
use Twilio\Rest\Verify\V2\Service\VerificationInstance;
use Twilio\Rest\Verify\V2\ServiceContext;
use function strtolower;

readonly class TwilioVerificationService
{
    private ServiceContext $verifyService;

    public function __construct(private Client $client, private string $serviceId)
    {
        $this->verifyService = $this->client
            ->verify
            ->v2
            ->services($this->serviceId);
    }

    public function sendVerificationCode(string $recipient, string $channel = 'sms'): VerificationInstance
    {
        return $this->verifyService
            ->verifications
            ->create($recipient, strtolower($channel));
    }

    public function validateVerificationCode(
        string $recipient,
        string $code
    ): VerificationCheckInstance {
        return $this->verifyService
            ->verificationChecks
            ->create([
                "to"   => $recipient,
                "code" => $code,
            ]);
    }
}

The class is initialised with a Twilio Rest Client and a Verify Service SID. The Rest Client, part of the official Twilio PHP Helper Library, makes it pretty trivial to interact with Twilio's APIs. The Verify Service SID is the unique id of the Verify Service that you'll use for sending and verifying validation codes.

It then defines two methods:

  • sendVerificationCode: This sends the verification code to the user's phone number ($recipient) via SMS
  • validateVerificationCode: This checks if a verification code ($code) sent to the user's phone via SMS ($recipient) is valid

Create the route's template file

As the final part of creating the LoginHandler, you need to create its view template. If you take another look at src/App/Handler/LoginHandler.php, you'll see the following line (formatted for slightly greater readability):

return new HtmlResponse(
    $this->renderer->render(
        'app::login', 
        $data
    )
);
A breakdown of the template file string, showing how the string is split into two parts: the namespace on the left and the template file's base name on the right.

The string on line three, "app::login", is how the template file is referenced. It's composed of two parts, the template namespace before the double colon and the template file's prefix after the double colon.

In this example, "app" is the template's namespace, and "login" is the template file's prefix. Given that, Twig will use login.html.twig in the templates/app directory.

How does Twig map the string to the template file?

Firstly, because of the following configuration in $config in public/index.php:

'templates' => [

    'paths' => [

        'app'    => [__DIR__ . '/../templates/app'],

        'error'  => [__DIR__ . '/../templates/error'],

        'layout' => [__DIR__ . '/../templates/layout'],

    ],

],

This defines Twig's template paths, and the directories that they point to. The keys of the templates → paths array element are the template namespaces and their respective keys are the directories where the template files for that namespace are located.

Then, it knows that template file names have the compound extension ".html.twig", thanks to vendor/mezzio/mezzio-twigrenderer/src/ConfigProvider's getTemplates() method. It defines the file name extension in the extension element returned in the array from the method.

With that out of the way, create a file named login.html.twig in templates/app, and in that file paste the code below.

{% extends '@layout/default.html.twig' %}
{% block title %}Login{% endblock %}
{% block content %}
    <h2 class="text-2xl text-slate-800 font-medium mt-8">Sign in to your account</h2>
    <section class="text-slate-700/75 mt-4 rounded-md p-6 drop-shadow-md bg-white border border-slate-100">
        {% if error is defined and not error is empty %}
            <div class="my-4 flex flex-row gap-x-2 items-center p-3 border-0 border-red-300 bg-red-100 rounded-md text-red-700">
                <img src="/images/icons/cancel.png" alt="upload fail icon" width="32" height="32">
                <p class="">{{ error }}</p>
            </div>
        {% endif %}
        <form class="gap-y-4 max-w-3xl" method="post" enctype="application/x-www-form-urlencoded">
            <label class="mb-2 block font-semibold" for="username">Username:</label>
            <input type="text"
                   name="username"
                   id="username"
                   required
                   class="w-full px-6 py-3 border-2 border-slate-300 rounded-sm bg-white">
            <input type="submit"
                   name="submit"
                   value="Login"
                   class="bg-slate-800 hover:bg-slate-700 py-2 px-6 text-white rounded-lg hover:cursor-pointer transition ease-in-out delay-150 duration-300 focus:inset-shadow-sm hover:inset-shadow-sm hover:drop-shadow-lg mt-4">
        </form>
    </section>
{% endblock %}
A diagram showing template inheritance, where one template inherits from or extends another.

The template starts off by extending its parent template layout/default.html.twig (you'll create it next) and setting the value of three blocks:

  • title: This sets the value of the page's title tag
  • header: This sets the page's main header or H1 tag
  • content: This sets the page's main content

Blocks act as placeholders in parent templates, which can be set or overridden in child templates. You're setting the page's title and main header to "Login", and the login form as the page's main content. Additionally, if the template variable error is set, then it is rendered above the login form in a styled DIV.

Define the parent template

Now, in templates/layout, create a file named default.html.twig. In the file, paste the code below.

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="/css/styles.css"
          rel="stylesheet">
    <title>{% block title %}{% endblock %} | Twilio Verify Protected Image Uploader</title>
</head>
<body class="p-5 text-slate-800 bg-slate-50">
<main class="flex flex-col justify-center">
    <div class="mx-auto w-full sm:w-xl">
        <h1 class="text-3xl mt-2 font-medium mb-2">Twilio Verify Protected Image Uploader</h1>
        {% block content %}{% endblock %}
    </div>
</main>
<footer class="pb-5 text-slate-600/85 mt-8 border-t-2 border-t-slate-200 pt-4">
    <div class="mx-auto w-3xl flex flex-col align-center justify-center">
        <p class="mt-0 pl-1 text-base text-center">
            <a href="https://www.google.com/maps/place/Bundaberg+Central+QLD+4670/@-24.8676767,152.3323474,15z/data=!4m6!3m5!1s0x6bebb94a0e6c5953:0x500eef17f210bc0!8m2!3d-24.8661024!4d152.3488923!16zL20vMGpsd3Y?entry=ttu&g_ep=EgoyMDI1MDUyMS4wIKXMDSoASAFQAw%3D%3D"
               target="_blank"
               class="underline underline-offset-2 decoration-2 decoration-slate-400 hover:decoration-slate-500 transition ease-in-out delay-150 duration-300">Made with 🖤 in Bundaberg</a> by
            <a href="https://matthewsetter.com"
               target="_blank"
               class="underline underline-offset-2 decoration-2 decoration-slate-400 hover:decoration-slate-500 transition ease-in-out delay-150 duration-300">Matthew Setter</a>.
    </div>
</footer>
</body>
</html>

The template is composed of the header and body, where the body is composed of two parts, the main element and the footer. You can see the blocks for the title, header, and content that are replaced in login.html.twig (and the other two routes' templates).

Define the verify route's handler

Now, it's time to create the "verify" route's handler. Start off by, in src/App/Handler, creating a file named VerifyHandler.php. Then, in the file, paste the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use App\Service\TwilioVerificationService;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use Mezzio\Session\SessionMiddleware;
use Mezzio\Template\TemplateRendererInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function array_key_exists;
use function array_keys;
use function in_array;

final readonly class VerifyHandler implements RequestHandlerInterface
{
    use FlashMessagesTrait;

    public function __construct(
        private TemplateRendererInterface $renderer,
        private TwilioVerificationService $verificationService,
        private array $users = []
    ) {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if ($request->getMethod() === 'GET') {
            $session = $request->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE);
            if (! $session->has('username')) {
                $this->setFlash($request, 'error', 'Username not available in request');
                return new RedirectResponse('/login');
            }
            return new HtmlResponse($this->renderer->render('app::verify', [
                'username' => $session->get('username'),
            ]));
        }
        $formData = $request->getParsedBody();
        if (
            ! array_key_exists('username', $formData)
            || ! array_key_exists('verification_code', $formData)
            || $formData['username'] === ''
            || $formData['verification_code'] === ''
            || ! in_array($formData['username'], array_keys($this->users))
        ) {
            return new RedirectResponse('/verify');
        }

        $result = $this->verificationService->validateVerificationCode(
            $this->users[$formData['username']],
            $formData['verification_code']
        );
        if ($result->status === 'approved') {
            return new RedirectResponse('/upload');
        }
    }
}

The class is instantiated just like LoginHandler.php, and its handle() method is structured in a similar way. It checks if the route was requested as a GET request. If so, it then checks if the username, set in LoginHandler, is available in the current session.

If not, it sets a flash message telling the user that before redirecting them back to the "login" route. Otherwise, it sets the username as a template variable, and renders app::verify as the body of the response.

If the route was requested as a POST request, it checks if the POST data has a "username" element and that its value is valid, and also contains the verification code in the "verification_code" element. If either of these pre-conditions fails, the user is redirected to the "verify" route so that they can submit the form again.

I've not set a flash message this time, so that the code is not overly long.

If the form is valid, it uses the Twilio verification service ($verificationService) to validate the code that the user received. If it is valid, the user is then redirected to the "upload" route.

At this point, you might set a user id or username in the session, or a boolean flag to indicate that they're authenticated. However, again, I've chosen not to for the sake of simplicity.

Define the route's template file

Now, in templates/app create a file named verify.html.twig, and in it paste the code below.

{% extends '@layout/default.html.twig' %}
{% block title %}Verify Your Passcode{% endblock %}
{% block content %}
    <h2 class="text-2xl text-slate-800 font-medium mt-8">Verify your passcode</h2>
    <section class="text-slate-700/75 mt-4 rounded-md p-6 drop-shadow-md bg-white border border-slate-100">
        <form class="gap-y-4 max-w-3xl" method="post" enctype="application/x-www-form-urlencoded">
            <div class="mb-1">
                <label class="mb-2 block font-medium text-slate-700/75" for="verification_code">
                    Verification code:
                </label>
                <input type="text"
                       id="verification_code"
                       name="verification_code"
                       minlength="6"
                       maxlength="6"
                       placeholder="Your 6-digit verification code"
                       class="w-full px-6 py-3 border-2 border-slate-300 rounded-sm">
            </div>
            <input type="submit"
                   name="submit"
                   value="Verify Code"
                   class="bg-slate-800 hover:bg-slate-700 py-2 px-6 text-white rounded-lg hover:cursor-pointer transition ease-in-out delay-150 duration-300 focus:inset-shadow-sm hover:inset-shadow-sm mt-4 mr-1">
            <input type="hidden"
                   id="username"
                   name="username"
                   value="{{ username }}">
        </form>
    </section>
{% endblock %}

Similar to templates/app/login.html.twig, it extends templates/layout/default.html.twig. It sets the page's title and main header to "Verify Your Passcode", and sets the content block to the verification form.

Define the upload route's handler

Finally, you'll create the "upload" route's handler. In src/App/Handler, create a file named UploadHandler.php. In the file, paste the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use InvalidArgumentException;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use Mezzio\Template\TemplateRendererInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;
use function array_key_exists;
use const UPLOAD_ERR_OK;

readonly class UploadHandler implements RequestHandlerInterface
{
    use FlashMessagesTrait;

    public function __construct(
        private TemplateRendererInterface $renderer,
        private array $uploadConfig = [],
    ) {
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if ($request->getMethod() === 'GET') {
            $status = $this->getFlash($request, 'status');
            return new HtmlResponse($this->renderer->render('app::upload', [
                'status' => $status,
            ]));
        }

        $formData = $request->getUploadedFiles();
        if (
            ! array_key_exists('file', $formData)
            || ! $formData['file'] instanceof UploadedFileInterface
        ) {
            return new RedirectResponse('/upload');
        }

        $file = $formData['file'];
        if ($file->getError() !== UPLOAD_ERR_OK) {
            return new RedirectResponse('/upload');
        }
        try {
            $file->moveTo($this->uploadConfig['upload_dir'] . $file->getClientFilename());
            $this->setFlash($request, 'status', 'Image uploaded successfully');
        } catch (InvalidArgumentException | RuntimeException $e) {
            return new RedirectResponse('/upload');
        }
        return new RedirectResponse('/upload');
    }
}

The class' handle() method renders templates/app/upload.html.twig if the route was requested as a GET request, with a template variable named "status". If set, this variable contains a message telling the user that the image upload was successful.

If the route was requested as a POST request, the files uploaded with the request are retrieved, before checking if there is one with the key/name "file". If it's not available, the user is redirected back to the "upload" form. Otherwise, the code checks that the file was uploaded successfully (getError() is set to UPLOAD_ERR_OK). If so, the file's moved to the upload directory and the status message is flashed, before redirecting the user to the "upload" route.

There are a number of other PHP file upload constants, if you're interested in checking for them as well.

Create the route's template file

As you did with the previous two handlers, you'll now create the "upload" route's template by creating a file named upload.html.twig in templates/app. In the file, paste the code below.

{% extends '@layout/default.html.twig' %}
{% block title %}Upload an image{% endblock %}
{% block content %}
    <h2 class="text-2xl text-slate-800 font-medium mt-8">Upload a PNG or JPG Image</h2>
    <section class="text-slate-700/75 mt-4 rounded-md p-6 drop-shadow-md bg-white border border-slate-100">
        {% if status is defined and not status is empty %}
            <div class="mt-4 flex flex-row gap-x-2 items-center p-3 border-0 border-green-300 bg-green-100 rounded-md text-green-700">
                <img src="/images/icons/success.png" alt="success icon" width="32" height="32">
                <p class="">{{ status }}</p>
            </div>
        {% endif %}
        {% if error is defined %}
            <div class="mt-4 flex flex-row gap-x-2 items-center p-3 border-0 border-red-300 bg-red-100 rounded-md text-red-700">
                <img src="/images/icons/cancel.png" alt="upload fail icon" width="32" height="32">
                <p class="">Image could not be uploaded</p>
            </div>
        {% endif %}
        <form class="gap-y-4 max-w-3xl" method="post" enctype="multipart/form-data">
            <label class="mb-2 block font-semibold" for="file">File name:</label>
            <input type="file"
                   name="file"
                   id="file"
                   accept="image/png, image/jpeg"
                   required
                   class="sm:w-full px-6 py-3 border-2 border-slate-300 rounded-sm">
            <input type="submit"
                   name="submit"
                   value="Upload File"
                   class="bg-slate-800 hover:bg-slate-700 py-2 px-6 text-white rounded-lg hover:cursor-pointer transition ease-in-out delay-150 duration-300 focus:inset-shadow-sm hover:inset-shadow-sm mt-4 mr-1">
            <a href="/upload" class="hover:cursor-pointer transition ease-in-out delay-150 duration-300 hover:underline">cancel</a>
        </form>
    </section>
{% endblock %}

The template renders a form with a file element named "file". It accepts only PNG and JPEG files. This only works for clients that respect the accept attribute, but it's part of limiting the allowed upload files that could be built on in a proper, deployable application.

It also renders a styled DIV above the form, depending on whether the file was uploaded successfully or not. That way, the user doesn't have to wonder, or check the data/uploads directory to see if the file's there.

I've built the stylesheet with Tailwind CSS. What's your preferred CSS framework?

Download the application's static assets

There's just two final things to do:

Test that the application works as expected

With the application now completed, it's time to test it. To do that, open http://localhost:8080/login, where it should look like the screenshot below.

Login page for Twilio Verify Protected Image Uploader with fields for username and password.

Enter the username set for YOUR_USERNAME in .env, which should still be user@example.org, and submit the form.

A webpage screen asking for a 6-digit verification code with a field to enter the code and a verify button.

You should receive a 6-digit code via SMS and be redirected to the verify route. Enter the code into the form and click Verify Code.

User interface for Twilio Verify Protected Image Uploader with file selection and upload options.

Assuming that all went well, you should be redirected to the upload route. Test out the form by picking a PNG or JPEG file from your local filesystem and uploading it by clicking Upload File.

Confirmation message of successful image upload on Twilio Verify Protected Image Uploader.

That's how to protect image uploads with PHP and Twilio Verify

The application isn't as full-featured and robust as you might expect. However, it does show how to protect it by integrating 2FA (Two-factor Authentication) with Twilio Verify. By doing that, you've seen how to create a more modern, secure, and robust user authentication process, ensuring that only valid users can use the application.

Matthew Setter is a PHP and Go Editor in 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 .

The image upload icon in the main image was created by JessHG, and the success icon was created by Talha Dogar on Flaticon.