Integrating Voice Call OTP Verification in a Symfony Application with Twilio

Integrating Voice Call OTP Verification in a Symfony Application with Twilio
January 04, 2024
Written by
Popoola Temitope
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

One-time passwords (OTPs) provide an additional layer of security by generating temporary, unique codes that users must enter to complete specific actions or access particular features.

While text messages serve as a common method of OTP delivery, voice-call OTP verification is also gaining popularity, due to its accessibility and reliability. When used, the user receives a voice call that verbally provides their verification code, which they subsequently enter into the application.

In this tutorial, you will learn how to integrate voice call OTP verification into a Symfony application to verify user logins using Twilio Programmable Voice. This will be achieved by creating registration and login pages, along with a verification page for users to enter the received OTP, enabling them to gain access to the application.

Prerequisites

To follow along with this tutorial, you will need the following:

Create a new Symfony project

Let's get started, by creating a new Symfony web project using the Symfony CLI. You can do this by running the following commands:

symfony new my-project --version="6.3.*" --webapp

Then, after the installation is complete, run the commands below to start the application server:

cd my-project/
symfony server:start

Once the application server is started, open http://localhost:8000/ in your preferred browser and you'll see the application's home page, as shown in the screenshot below.

The default Symfony application welcome page

Set up the database

To connect the application to the MySQL database, you must first install Doctrine. You can install it by running the following commands in your terminal:

​​composer require symfony/orm-pack
composer require --dev symfony/maker-bundle

Now, to configure the database connection, open the .env file in the project's root directory. Then, comment out the existing DATABASE_URL setting. After that,  add the following configuration to the end of that section (doctrine/doctrine-bundle).

DATABASE_URL="mysql:/</db_user>:<db_password>@127.0.0.1:3306/<db_name>"

In the configuration above, replace <db_user> with the database's username, <db_password> with the database's password, and <db_name> with the name of the database.

Then, to create the configured database, if it's not already provisioned, run the command below in your terminal.

php bin/console doctrine:database:create

Create the required entities

An entity is a PHP class that represents a database table schema, mapping its properties to table columns for effective interaction between the application and the database.

Now, let's create an entity class and add fields to the database table using the make:entity command below. Running it will prompt you with questions about the new entity class.

php bin/console make:entity

The above command will prompt you to enter the entity name and then set the database table schema for the entity. Answer the prompts as shown in the screenshot below.

Bootstrapping a new Symfony Entity in the macOS terminal with the make:entity command from the Symfony console.

Next, run the following commands to add and update the created entity fields to the database.

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Create a Form Type

A form type is a class that defines the structure and behavior of a form in a Symfony application. It specifies the fields, their types, validation rules, and other attributes of the form.

Before you create the routes for the registration, login, and verification pages, let's first create the form types for both registration and login. The form type is used to define the form fields. To create a registration form type, run the command below in your terminal.

php bin/console make:form RegistrationType

Running the above command will prompt you to enter the entity's name. Enter the entity name UserInfo that you created earlier.

Now, in your code editor, if you navigate to the src/Form folder and open RegistrationType.php, you will find that it looks like the following.

<?php

namespace App\Form;

use App\Entity\Userinfo;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class RegistrationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('fullname')
            ->add('phone')
            ->add('username')
            ->add('password')
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Userinfo::class,
        ]);
    }
}

Now, let’s ensure that the user’s password is not exposed. To do so, in the code above, update the buildForm() method to match the followings:

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        ->add('fullname')
        ->add('phone')
        ->add('username')
        ->add('password', PasswordType::class);
}

Then, add the following use statement to the top of the file.

use Symfony\Component\Form\Extension\Core\Type\PasswordType;

Next, let’s create a login form type, using the same process above, by running the command below.

php bin/console make:form LoginFormType

When prompted, enter your entity name, input UserInfo. In the src/Form folder, open the generated LoginFormType.php file and replace it with the following code to remove all unnecessary fields.

<?php

namespace App\Form;

use App\Entity\Userinfo;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;

class LoginFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('username')
            ->add('password', PasswordType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Userinfo::class,
        ]);
    }
}

Create the authentication controller

Let's create the controller for our application and install the HttpFoundation package to work with sessions in the application, by running the following commands in your terminal.

php bin/console make:controller Authentication
composer require symfony/http-foundation

Now, navigate to the src/controller directory and open AuthenticationController.php. Then, define the application route and its logic by replacing the existing code with the following.

<?php

namespace App\Controller;

use App\Entity\Userinfo;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use App\Form\RegistrationType;
use App\Form\LoginFormType;
use Symfony\Component\HttpFoundation\Session\SessionInterface;

class AuthenticationController extends AbstractController
{
    #[Route('/authentication/register')]
    public function register(Request $request, EntityManagerInterface $entityManager): Response
    {
        $Userinfo = new Userinfo();
        $mxg = "";
        $form = $this->createForm(RegistrationType::class, $Userinfo);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager->persist($Userinfo);
            $entityManager->flush();
            return $this->redirect('login?mxg=Registration successful. Please login.');

        }
        return $this->render('authentication/register.html.twig', ['message' => $mxg, 'form' => $form->createView()]);
    }
}

In the code above:

  • All the necessary dependencies are imported
  • The route for the registration page is defined, where form submission data is validated and then stored in the database
  • The registration form template is loaded from authentication/register.html.twig

To create the registration template, navigate to the templates/authentication directory and create a new file named register.html.twig. Inside the file, add the following code:

{% extends 'base.html.twig' %}

{% block title %}Register{% endblock %}

{% block body %}
    <h1>Registration</h1>
    {{ message }}
    {{ form_start(form) }}
    {{ form_row(form.fullname) }}
    {{ form_row(form.username) }}
    {{ form_row(form.phone) }}
    {{ form_row(form.password) }}<br/>
    <button type="submit">Register</button><br/>
    <span>Already have an account,<a href="login">login here</a></span>
    {{ form_end(form) }}
{% endblock %}

Finally, in templates/base.html.twig, add the following block in the <head> section, so that the templates are styled properly.

<style>
    body {
        font-family: Arial, sans-serif;
        text-align: center;
    }

    h1 {
        color: #333;
    }

    form {
        width: 300px;
        margin: 0 auto;
        padding: 20px;
        background: #f5f5f5;
        border: 1px solid #ccc;
        border-radius: 5px;
    }

    form div {
        margin-bottom: 17px;
        display: inline-block;
        width: 100%;
    }

    label {
        display: block;
        text-align: left;
        margin-top: 10px;
        font-weight: bold;
    }

    input[type="text"], input[type="password"] {
        width: 93%;
        padding: 10px;
        margin-top: 5px;
        border: 1px solid #ccc;
        border-radius: 5px;
        font-size: large;
    }

    button[type="submit"] {
        display: block;
        width: 100%;
        padding: 10px;
        background: #007bff;
        color: #fff;
        border: none;
        border-radius: 5px;
        cursor: pointer;
        font-size: large;
    }

    button[type="submit"]:hover {
        background: #0056b3;
    }
</style>

Now, save your work and open http://localhost:8000/authentication/register in your browser. Then, register a new account using your phone number (including your country code) as shown in the image below.

The registration page of the application with the new user"s full name, username, phone number, and password entered into the registration form.

Install the Twilio PHP SDK

To interact with Twilio Programmable Voice, you need to install the Twilio SDK for PHP. Run the command below to do so.

composer require twilio/sdk

You can get your Account SID and Auth Token from your Twilio Console dashboard, as shown in the image below.

The Account Info panel of the Twilio console dashboard, with part of the Account SID and My Twilio phone number fields redacted.

Storing the access token in the .env file

To ensure that the Twilio API credentials are well secured, let’s store them in the .env file. To do so, open the .env file in the project's root folder and add the following code.

TWILIO_ACCOUNT_SID=<twilio_account_sid>
TWILIO_AUTH_TOKEN=<twilio_auth_token>
TWILIO_PHONE_NUMBER=<twilio_phone_number>

In the code above, replace <twilio_account_sid>, <twilio_auth_token> and <twilio_phone_number> with your corresponding Twilio values.

Add functionality for sending a verification code

Let's create a function that will connect to Twilio Programmable Voice and send the verification code through a phone call to the user. Inside the src folder, create a Service folder. Inside this folder, create a file named TwilioService.php and add the following code to it.

<?php

namespace App\Service;

use Twilio\Rest\Client;

class TwilioService
{
    public function sendVoiceOTP($recipientPhoneNumber, $otpCode)
    {
        $accountSid = $_ENV['TWILIO_ACCOUNT_SID'];
        $authToken = $_ENV['TWILIO_AUTH_TOKEN'];
        $twilioPhoneNumber = $_ENV['TWILIO_PHONE_NUMBER'];
        $client = new Client($accountSid, $authToken);
        $call = $client->calls->create(
            $recipientPhoneNumber,
            $twilioPhoneNumber, 
            [
                'twiml' => '<Response><Say>Your OTP code is ' . $otpCode . '. Once again, your OTP code is ' . $otpCode . '.</Say></Response>'
            ]
        );
        return $call->sid;
    }
}

From the code above, the sendVoiceOTP() function contains two arguments (the $recipientPhoneNumber and $otpCode). The OTP message to send a voice call is passed, wrapped in the Say element, which will say the code twice, before hanging up the call.

Create the login controller

Next, let's add a login route to our controller. This route will allow users to log in with their username and password. After logging in, an OTP will be sent to the user's registered phone number as an additional authentication method. To do that, open AuthenticationController.php and add the following function to it:

#[Route('/authentication/login')]
public function login(Request $request, EntityManagerInterface $entityManager, SessionInterface $session, TwilioService $twilioService): Response
{
    $Userinfo = new Userinfo();
    $OTP = null;
    $mxg = $request->query->get('mxg');
    $form = $this->createForm(LoginFormType::class, $Userinfo);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $username = $form->get('username')->getData();
        $password = $form->get('password')->getData();

        $repository = $entityManager->getRepository(Userinfo::class);
        $login = $repository->findOneBy([
            'username' => $username,
            'password' => $password,
        ]);

        if ($login !== null) {
            $phone = $login->getPhone();
            $OTP = random_int(10001, 90009);
            $session->set('otp', $OTP);
            $session->set('phone', $phone);

            $twilioService->sendVoiceOTP($phone, $OTP);
                
            return new RedirectResponse($this->generateUrl('verify'));
        } else {
            $mxg="Invalid login Username/Password";
        }
    }
    
    return $this->render('authentication/login.html.twig', [
       'form' => $form->createView(),
        'otp' => $OTP,
        'message'=>$mxg,
    ]);
}

Then, add the following use statement to the top of the file.

use App\Service\TwilioService;

To create the login template, navigate to the templates/authentication folder and create a new file named login.html.twig. Inside the file, add the following code.

{% extends 'base.html.twig' %}

{% block title %}Login{% endblock %}

{% block body %}
    <h1>Login</h1>
    <span>{{message}}</span>
    {{ form_start(form) }}
    {{ form_row(form.username) }}
    {{ form_row(form.password) }}
    <br/>
    <button type="submit">Login</button><br/>
    <span>New user? Create new account <a href="register"> here</a></span>
    {{ form_end(form) }}
{% endblock %}

Now, open http://localhost:8000/authentication/login in your browser. Log in with your username and password. You will receive a call from your Twilio phone number which will provide your verification code. Afterward, you will be redirected to the verification page where you can enter the OTP code.

The login form of the application with the user"s username and password already filled in.

Add the verification controller

Now, to add the verify route and check if the entered verification code is correct or not, add the following code to AuthenticationController.php.

#[Route('/authentication/verify', name: 'verify')]
public function verify(Request $request, SessionInterface $session): Response
{
    if (null !== $session->get('phone')) {
        $otpFromForm = $request->request->get('otp'); 
        $sessionOtp = $session->get('otp');
        $sessionPhone = $session->get('phone');
        $mxg = "";    
        if ($otpFromForm == $sessionOtp) {
            $mxg = 'Code verified successfully.';
        } else {
            if ($otpFromForm == "") {
               $mxg =  '';
            } else {
                $mxg = 'Verification code is incorrect.';
            }
            // return new RedirectResponse($this->generateUrl('dashboard'));
        }
        return $this->render('authentication/verify.html.twig', ['message' => $mxg, 'phone' => $sessionPhone]);
    } else {
        return $this->redirect('login');
    }
}

Once the OTP is correct, you can redirect the user to your preferred route. Next, you need to create the verify template. Inside the authentication folder, create a file named verify.html.twig and add the following code to it.

{% extends 'base.html.twig' %}

{% block title %}Verify{% endblock %}

{% block body %}
    <h1>Login verification</h1>
    <form method="post">
        <span>You'll receive a call on {{phone}} with your verification code.</span>
        <br><br> Please enter the code below: <br><br>
        <span>{{message}}</span>
        <div>
            <input type="text" name="otp" required><br>
        </div>
        <button type="submit" >Verify</button>
    </form>
{% endblock %}

Now, save your work and log in to your account. You will receive a call from your Twilio phone number, providing your verification code to complete the login. Enter the OTP on the verification page as shown below.

The Login Verification page of the application.

Next, enter the OTP and click on the Verify button. If the OTP is correct, you will see a success message, as shown in the image below.

The Login Verification page success message.

After verifying the OTP, you can redirect the user to their desired page, such as the dashboard, profile, or settings page.

Conclusion

In this tutorial, you learned how to integrate the Twilio Programmable Voice API into a Symfony-based application to serve one-time passwords, as an additional authentication factor for user logins.

By leveraging this method of OTP delivery, users can receive one-time passwords via telephone calls and gain access to their application with greater security.

Popoola Temitope is a mobile developer and a technical writer who loves writing about frontend technologies. He can be reached on LinkedIn.