How to Create a Customer Satisfaction Survey using Twilio Programmable Voice and PHP

February 04, 2025
Written by
Godwin Agedah
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Create a Customer Satisfaction Survey Using PHP and Twilio Programmable Voice

You’ve successfully shipped multiple products and/or rendered services to your customers. But, how do you get rating feedback from them?

We can use Twilio Programmable Voice to do this. Using voice provides reach, because customers may not actively come online to rate us. What's more, with voice we can initiate the feedback process.

Sounds interesting?

Then in this tutorial, we will build a system where we can create questions for which we would like feedback. When called, customers can press digits 1 - 5 on their phones to give us their feedback. In addition, it will have a user interface (UI) to see the feedback.

Prerequisites

For this project, we will need:

Create the core of the application

The first thing to do is to create a new directory for the project and install the required dependencies. So, wherever you store your PHP projects, run the following commands to create the project directory and navigate into it.

mkdir customer-satisfaction-survey
cd customer-satisfaction-survey

Then, install Twilio's PHP Helper Library and PHP Dotenv to enable us to load our Twilio credentials from a dotenv file ( .env, which will be set up next). To do that, run the following command.

composer require twilio/sdk vlucas/phpdotenv

Set the required environment variables

Let's add our database and Twilio credentials as environment variables. Saving them there will ensure we can use them anywhere in our application. Create a new file in your project's top-level directory, named .env, and then paste the configuration below into it.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<your_database>
DB_USERNAME=<your_database_username>
DB_PASSWORD=<your_database_password>
TWILIO_SID=<your_twilio_account_sid>
TWILIO_AUTH_TOKEN=<your_twilio_auth_token>
TWILIO_PHONE_NUMBER=<your_twilio_phone_number>

Next, log in to your Twilio Dashboard and obtain your Account SID, Auth Token, and Twilio phone number in the Account Info panel, as seen below.

Twilio account info showing Account SID, Auth Token, safety reminder, and linked phone number.

After that, replace <your_twilio_account_sid>, <your_twilio_auth_token>, and <your_twilio_phone_number>, respectively, in .env with your Twilio credentials. Then, update the database variables with your database credentials.

Set up the database

Let's set up our database, using the following SQL commands to create the schema. Run them in your database client of choice.

BEGIN TRANSACTION;

CREATE TABLE surveys (
    id INT NOT NULL AUTO_INCREMENT,
    title TEXT NOT NULL,
    description TEXT NOT NULL,
    opening_message TEXT NOT NULL,
    created_at TIMESTAMP NULL DEFAULT NULL,
    updated_at TIMESTAMP NULL DEFAULT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE questions (
    id INT NOT NULL AUTO_INCREMENT,
    survey_id INT NOT NULL,
    question TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY (survey_id),
    CONSTRAINT questions_survey_id_foreign 
        FOREIGN KEY (survey_id) 
            REFERENCES surveys (id) 
                ON DELETE CASCADE
);

CREATE TABLE survey_customers (
    id INT NOT NULL AUTO_INCREMENT,
    survey_id INT NOT NULL,
    phone VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY (survey_id),
    CONSTRAINT survey_customers_survey_id_foreign 
        FOREIGN KEY (survey_id) 
            REFERENCES surveys (id) 
                ON DELETE CASCADE
);

CREATE TABLE survey_customers_rating (
    id INT NOT NULL AUTO_INCREMENT,
    survey_id INT NOT NULL,
    question_id INT NOT NULL,
    survey_customers_id INT NOT NULL,
    phone VARCHAR(255) NOT NULL,
    rating INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY (survey_id),
    KEY (question_id),
    KEY (survey_customers_id),
    CONSTRAINT survey_customers_rating_survey_id_foreign 
        FOREIGN KEY (survey_id) 
            REFERENCES surveys (id) 
                ON DELETE CASCADE,
    CONSTRAINT survey_customers_rating_question_id_foreign 
        FOREIGN KEY (question_id) 
            REFERENCES questions (id) 
                ON DELETE CASCADE,
    CONSTRAINT survey_customers_rating_survey_customers_id_foreign 
        FOREIGN KEY (survey_customers_id) 
            REFERENCES survey_customers (id) 
                ON DELETE CASCADE
);

COMMIT;

The above SQL commands create four tables:

  1. surveys: This table stores information about each survey
  2. questions: This table stores the individual questions for each survey
  3. survey_customers: This table stores the phone numbers of customers who will take the surveys
  4. survey_customers_rating: This table stores the ratings provided by customers for each question in a survey

The tables have the following relationships:

  • surveys is our primary table
  • questions are linked to surveys
  • survey_customers are linked to surveys
  • survey_customers_rating links survey_customers, questions, and surveys

Create a database connection

Our database has been initialized, so we can now connect to it. To do that, we will create a Database.php file with the code below in the project's top-level directory. This will load up our database variables and connect to our database.

<?php

namespace Database;

use Dotenv\Dotenv;
use PDO;

class Database
{
    private PDO $pdo;

    public function __construct()
    {
        $dotenv = Dotenv::createImmutable(__DIR__);
        $dotenv->load();
        $dsn = 'mysql:host=' . $_ENV['DB_HOST'] . ';dbname=' . $_ENV['DB_DATABASE'];
        $this->pdo = new PDO($dsn, $_ENV['DB_USERNAME'], $_ENV['DB_PASSWORD']);
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    public function getConnection(): PDO
    {
        return $this->pdo;
    }
}

Set up autoloading for the application's code

Our application will consist of multiple directories and files with classes in line with modern PHP design patterns. To do this, add the following configuration to composer.json file.

"autoload": {
    "psr-4": {
        "App\\": "app/",
        "Database\\": ""
    }
}

The above configuration tells Composer's autoloader how to map namespaces to directories. These directories will be created as we build our project.

Next, you need to update Composer's Autoloader file by running the following command.

composer dump-autoload

Add the application's routing table

To set up routing, create a directory named public in the project's top-level directory, and in that directory create a file named index.php. index.php will bootstrap the application and serve as the entry point of our project, handling requests to URLs in our app.

Once the file and directory have been created, paste the code below into public/index.php.

<?php

session_start();

require_once '../vendor/autoload.php';

use Database\Database;

$requestUri = $_SERVER['REQUEST_URI'];
$urlComponents = parse_url($requestUri);
$requestUri = $urlComponents['path'];
$requestUri = trim($requestUri, '/');
$requestParts = explode('/', $requestUri);
$controller = !empty($requestParts[0]) ? $requestParts[0] : 'survey';
$action = !empty($requestParts[1]) ? $requestParts[1] : 'index';
$id = isset($requestParts[2]) ? $requestParts[2] : null;
$controllerClass = 'App\\Controllers\\' . ucfirst($controller) . 'Controller';
$db = new Database();
$pdo = $db->getConnection();

if (class_exists($controllerClass)) {
    $controllerInstance = new $controllerClass($pdo);
    if (method_exists($controllerInstance, $action)) {
        $controllerInstance->$action($id);
    } else {
        echo "Action not found!";
    }
} else {
    echo "Controller class not found!";
}

The code above splits the request URI and deduces the method to execute in the corresponding application controller, which we will create later.

Set up the application's user interface

Next, let’s set up the application's user interface (UI) for creating surveys and adding our customers' phone numbers and questions for which we would like to get a rating.

To do that, create an app directory at the root of the project. Then, inside that directory, create three further directories: Controllers, Models, and views. These will be used to separate concerns in our project.

After that, create a file named layout.php in the app/views directory. This file will contain our UI navigation, and the dynamic content of our pages will be injected into the layout so we do not repeat ourselves. With that done, add the following code to app/views/layout.php.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <script src="https://cdn.tailwindcss.com"></script>
        <title>Customer Satisfaction Survey</title>
  </head>
  <body class="bg-gray-100">
        <nav class="bg-gray-800 p-6">
            <div class="max-w-6xl mx-auto">
                <div class="flex items-center justify-between h-4">
                    <div class="flex items-center">
                        <div class="flex-shrink-0">
                            <a href="/" class="text-white text-lg font-semibold flex gap-1 items-center">
                                <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-500" viewBox="0 0 20 20" fill="currentColor">
                                    <path d="M10 1.5l1.902 5.797h6.146L12.9 9.552 14.804 15 10 11.5 5.196 15l1.904-5.448L1.951 7.297h6.146L10 1.5z" />
                                </svg>
                                Customer Satisfaction Survey
                            </a>
                        </div>
                    </div>
                <div class="md:block">
                    <div class="ml-4 flex items-center md:ml-6">
                        <a href="/survey/create" class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">Create
                        </a>
                        <a href="/survey/" class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">View</a>
                    </div>
                </div>
            </div>
        </div>
    </nav>
    <div class="max-w-7xl mx-auto p-4">
        <div class="bg-white">
            <?php echo isset($content) ? $content : ''; ?>
        </div>
    </div>
</body>
</html>

Next, is the form for creating a survey. In our app/views directory, create a directory named survey; inside that directory, create a file named create.php. This file will contain the form where the surveys will be created.

The form will have the following inputs:

  • Title of the survey
  • Description
  • Our opening message
  • Survey questions
  • Customer phone numbers

Inside app/views/survey/create.php, paste the following code below.

<div class="mx-auto max-w-7xl px-4 py-24 sm:px-6 sm:py-20 lg:px-8">
    <div class="mx-auto max-w-2xl">
        <form method="POST">
            <?php if (isset($_SESSION['message'])): ?>
                <div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 mb-4" role="alert">
                    <p class="font-bold">Success!</p>
                    <p> 
                        <?php echo $_SESSION['message']; ?>
                        <?php unset($_SESSION['message']); ?>
                    </p>
                </div>
            <?php endif; ?>
            <div class="space-y-12">
                <div class="border-b border-gray-900/10 pb-12">
                    <h1 class="text-xl font-semibold leading-7 text-gray-900">Create:</h1>
                    <p class="mt-1 text-sm leading-6 text-gray-600">Create your feedback survey</p>
                    <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
                        <div class="col-span-full">
                            <label for="username" class="block text-sm font-medium leading-6 text-gray-900">Title</label>
                            <div class="mt-2">
                                <input id="title" name="title" type="title" autocomplete="title" placeholder="Enter Title" value="Customer Satisfaction Survey" required class="px-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 sm:text-sm sm:leading-6">
                            </div>
                        </div>
                        <div class="col-span-full">
                            <label for="about" class="block text-sm font-medium leading-6 text-gray-900">Description</label>
                            <div class="mt-2">
                                <textarea id="about" name="description" rows="2" placeholder="Enter Description" required class="px-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 sm:text-sm sm:leading-6"></textarea>
                            </div>
                        </div>
                        <hr class="col-span-full" />
                        <div class="col-span-full">
                            <label for="about" class="block text-sm font-medium leading-6 text-gray-900">Opening Message:</label>
                            <div class="mt-2">
                                <textarea id="about" name="opening_message" rows="2" placeholder="Opening Message" class="px-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 sm:text-sm sm:leading-6">Thank you for choosing our Service. Please rate each statement from 1 (very dissatisfied) to 5 (very satisfied) on your phone. Your feedback is important to us.</textarea>
                            </div>
                        </div>
                        <div class="col-span-full">
                            <label for="street-address" class="block text-sm font-medium leading-6 text-gray-900">Question(s)</label>
                            <div id="input-container">
                                <div class="mt-2 flex gap-4 items-center input-item">
                                    <span>1:</span>
                                    <input type="text" name="questions[]" id="question-1" autocomplete="street-address" placeholder="Question 1" value="How satisfied are you with our service overall?" class="px-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 sm:text-sm sm:leading-6">
                                    <button class="remove-button">✖️</button>
                                </div>
                            </div>
                            <button id="add-input" type="button" class="text-sm font-semibold leading-6 text-gray-900 my-3">+ Add</button>
                        </div>
                        <div class="col-span-4">
                            <label for="phone" class="block text-sm font-medium leading-6 text-gray-900">Customer Phone Number(s)</label>
                            <div id="phone-container">
                                <div class="mt-2 flex gap-4 items-center phone-item">
                                    <span>1:</span>
                                    <input type="text" name="survey_customers[]" id="phone" autocomplete="phone" placeholder="Phone 1" value="" class="px-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600 sm:text-sm sm:leading-6">
                                    <button class="remove-button">✖️</button>
                                </div>
                            </div>
                            <button id="add-phone-input" type="button" class="text-sm font-semibold leading-6 text-gray-900 my-3">+ Add</button>
                        </div>
                    </div>
                </div>
            </div>
            <div class="mt-6 flex items-center justify-end gap-x-6">
                <button type="button" class="text-sm font-semibold leading-6         text-gray-900">Cancel</button>
                <button type="submit" class="rounded-md bg-gray-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600">Send Calls</button>
            </div>
        </form>
    </div>
</div>

Now, let's add the following JavaScript to the end of the create.php file to enable us to add survey questions and customer phone number input fields using DOM manipulation.

<script>
window.addEventListener("load", () => {
    const inputContainer = document.getElementById("input-container");
    const addButton = document.getElementById("add-input");
    let inputCount = 1;
    function initializeForm() {
        const initialInput = inputContainer.querySelector(".input-item");
        initialInput.classList.remove("hidden");
        const initialRemoveButton = initialInput.querySelector(".remove-button");
        initialRemoveButton.addEventListener("click", () => {
        initialInput.remove();
        updateLabels();
    });
    addButton.disabled = false;
}

function addInput() {
    inputCount++;
    const inputDiv = document.createElement("div");
    inputDiv.classList.add(
        "mt-2",
        "flex",
        "gap-4",
        "items-center",
        "input-item"
    );
    const label = document.createElement("span");
    label.textContent = `${inputCount}:`;
    const newInput = document.createElement("input");
    newInput.type = "text";
    newInput.name = `questions[]`;
    newInput.id = `question-${inputCount}`;
    newInput.autocomplete = "street-address";
    newInput.placeholder = `Question ${inputCount}`;
    newInput.classList.add(
        "px-2",
        "block",
        "w-full",
        "rounded-md",
        "border-0",
        "py-1.5",
        "text-gray-900",
        "shadow-sm",
        "ring-1",
        "ring-inset",
        "ring-gray-300",
        "placeholder:text-gray-400",
        "focus:ring-2",
        "focus:ring-inset",
        "focus:ring-gray-600",
        "sm:text-sm",
        "sm:leading-6"
    );
    const removeButton = document.createElement("button");
    removeButton.textContent = "✖️";
    removeButton.classList.add("remove-button");
    removeButton.addEventListener("click", () => {
        inputDiv.remove();
        updateLabels();
    });
    inputDiv.appendChild(label);
    inputDiv.appendChild(newInput);
    inputDiv.appendChild(removeButton);
    inputContainer.appendChild(inputDiv);
    function updateLabels() {
        const inputItems = inputContainer.querySelectorAll(".input-item");
        inputItems.forEach((item, index) => {
            const label = item.querySelector("span");
            label.textContent = `${index + 1}:`;
            const input = item.querySelector("input");
            input.placeholder = `Question ${index + 1}`;
        });
    }
}

initializeForm();

addButton.addEventListener("click", addInput);
const phoneContainer = document.getElementById("phone-container");
const addPhoneButton = document.getElementById("add-phone-input");
let phoneCount = 1;

function initializePhoneForm() {
    const initialPhoneInput = phoneContainer.querySelector(".phone-item");
    initialPhoneInput.classList.remove("hidden");
    const initialRemoveButton = initialPhoneInput.querySelector(".remove-button");
    initialRemoveButton.addEventListener("click", () => {
        initialPhoneInput.remove();
        updatePhoneLabels();
    });
      addPhoneButton.disabled = false; // Enable add button after initialization
}

function addPhoneInput() {
    phoneCount++;
    const phoneDiv = document.createElement("div");
    phoneDiv.classList.add(
        "phone-item",
        "mt-2",
        "flex",
        "gap-4",
        "items-center"
    );
    const label = document.createElement("span");
    label.textContent = `${phoneCount}:`;
    const newPhoneInput = document.createElement("input");
    newPhoneInput.type = "text";
    newPhoneInput.name = `survey_customers[]`;
    newPhoneInput.id = `phone-${phoneCount}`;
    newPhoneInput.autocomplete = "phone";
    newPhoneInput.placeholder = `Phone ${phoneCount}`;
    newPhoneInput.classList.add(
        "px-2",
        "block",
        "w-full",
        "rounded-md",
        "border-0",
        "py-1.5",
        "text-gray-900",
        "shadow-sm",
        "ring-1",
        "ring-inset",
        "ring-gray-300",
        "placeholder:text-gray-400",
        "focus:ring-2",
        "focus:ring-inset",
        "focus:ring-gray-600",
        "sm:text-sm",
        "sm:leading-6"
    );
    const removePhoneButton = document.createElement("button");
    removePhoneButton.textContent = "✖️";
    removePhoneButton.classList.add("remove-button");
    removePhoneButton.addEventListener("click", () => {
        phoneDiv.remove();
        updatePhoneLabels();
    });
    phoneDiv.appendChild(label);
    phoneDiv.appendChild(newPhoneInput);
    phoneDiv.appendChild(removePhoneButton);
    phoneContainer.appendChild(phoneDiv);
    function updatePhoneLabels() {
        const phoneItems = phoneContainer.querySelectorAll(".phone-item");
        phoneItems.forEach((item, index) => {
            const label = item.querySelector("span");
            label.textContent = `${index + 1}:`;
            const input = item.querySelector("input");
            input.placeholder = `Phone ${index + 1}`;
        });
    }
}

initializePhoneForm();

addPhoneButton.addEventListener("click", addPhoneInput);
});
</script>

Add the application's database models

Now, let's create our models, which will have methods to save surveys, questions, and customers' phone numbers to our database.

Create a Survey.php file in our app/Models directory. Then, add the code below to the file.

<?php

namespace App\Models;

class Survey
{
    private $pdo;

    public function __construct($pdo)
    {
        $this->pdo = $pdo;
    }

    public function find($id)
    {
        $stmt = $this->pdo->prepare('SELECT * FROM surveys WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch(\PDO::FETCH_ASSOC);
    }

    public function create($data)
    {
        $stmt = $this->pdo->prepare(
            "INSERT INTO surveys (title, description, opening_message, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
        );
        $stmt->execute([
            $data["title"],
            $data["description"],
            $data["opening_message"],
            date("Y-m-d H:i:s"),
            date("Y-m-d H:i:s"),
        ]);
        return $this->pdo->lastInsertId();
    }
}

The above code defines a Survey class. It has a create() method containing our SQL statement to save a survey to our database, and a find() method which uses a survey ID parameter to get a survey by ID from our database.

Now, create a Question.php file in our app/Models directory, and add the following code into the file.

<?php

namespace App\Models;

class Question
{
    private $pdo;

    public function __construct($pdo)
    {
        $this->pdo = $pdo;
    }

    public function findBySurvey($survey_id)
    {
        $stmt = $this->pdo->prepare('SELECT * FROM questions WHERE survey_id = ?');
        $stmt->execute([$survey_id]);
        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }

    public function create($question, $survey_id)
    {
        $stmt = $this->pdo->prepare(
            "INSERT INTO questions ( question, survey_id, created_at, updated_at) VALUES (?, ?, ?, ?)"
        );
        return $stmt->execute([
            $question,
            $survey_id,
            date("Y-m-d H:i:s"),
            date("Y-m-d H:i:s"),
        ]);
    }
}

The above code has a create() method to save questions, and a findBySurvey() method to find all questions for a given survey.

Now, create a file named SurveyCustomer.php in the app/Models directory and insert the following code into the file.

<?php

namespace App\Models;

use PDO;

class SurveyCustomer
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function findByPhoneAndSurvey($phone, $survey_id)
    {
        $stmt = $this->pdo->prepare('SELECT * FROM survey_customers WHERE survey_id = ? AND phone = ?');
        $stmt->execute([$survey_id, $phone]);
        return $stmt->fetch(\PDO::FETCH_ASSOC);
    }

    public function create($phone, $survey_id)
    {
        $stmt = $this->pdo->prepare(
            "INSERT INTO survey_customers (phone, survey_id, created_at, updated_at) VALUES (?, ?, ?, ?)"
        );
        return $stmt->execute([
            $phone,
            $survey_id,
            date("Y-m-d H:i:s"),
            date("Y-m-d H:i:s"),
        ]);
    }
}

The above code has a create() method that saves the customer phone numbers we want to call into our database, and a findByPhoneAndsurvey() method to help us get a particular customer by phone number and survey ID.

Finally, we need a model to save our customer feedback to a question for a given survey. To do this create a file named SurveyCustomerRating.php in the app/Models directory. Then add the following code to the file.

<?php

namespace App\Models;

use PDO;

class SurveyCustomerRating
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function create($data)
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO survey_customers_rating (survey_id, question_id, survey_customers_id, phone, rating, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?)'
        );
        return $stmt->execute(
            [
                $data['survey_id'],
                $data['question_id'],
                $data['survey_customers_id'],
                $data['phone'],
                $data['rating'],
                date('Y-m-d H:i:s'),
                date('Y-m-d H:i:s')
            ]
        );
    }
}

Set up the survey controllers

Controllers in a web application are responsible for showing the appropriate view, handling user input, and updating models.

Create a SurveyController.php file in the app/Controllers directory to handle our survey routes, and paste the code below into the file.

<?php

namespace App\Controllers;

use App\Models\{Survey, Question, SurveyCustomer, SurveyCustomerRating};

class SurveyController
{
    private $pdo;
    private $surveyModel;
    private $questionModel;
    private $surveyCustomerModel;
    private $surveyCustomerRatingModel;

    public function __construct($pdo)
    {
        $this->pdo = $pdo;
        $this->surveyModel = new Survey($pdo);
        $this->questionModel = new Question($pdo);
        $this->surveyCustomerModel = new SurveyCustomer($pdo);
        $this->surveyCustomerRatingModel = new SurveyCustomerRating($pdo);
    }
}

This class will serve as a handler to show our survey form and handle form submissions.

Add functionality for viewing and creating surveys

In our SurveyController class, we will add a create() method to show our create survey form in response to GET requests, by pasting the following code at the end of the class.

public function create()
{
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $survey_id = $this->surveyModel->create($_POST);
        $questions = $_POST['questions'];
        foreach ($questions as $index => $question) {
            $this->questionModel->create($question, $survey_id);
        }
        $phoneNumbers = $_POST['survey_customers'];
        foreach ($phoneNumbers as $index => $phone) {
            $this->surveyCustomerModel->create($phone, $survey_id);
        }
        $_SESSION['message'] = 'Survey created successfully!';
        header('Location: /survey/create');
        exit;
    }

    ob_start();
    include '../app/views/survey/create.php';
    $content = ob_get_clean();
    include '../app/views/layout.php';
}

The create() method will save the survey when the form is submitted. After saving the survey, we can initiate phone calls with our customers.

Add the ability to initiate phone calls

First, create a new file named TwilioController.php file in our app/Controllers directory, then paste the code below into the file.

<?php

namespace App\Controllers;

use App\Models\{Survey, Question, SurveyCustomer, SurveyCustomerRating};
use Twilio\Rest\Client;
use Twilio\TwiML\VoiceResponse;

class TwilioController
{
    private $pdo;
    private $surveyModel;
    private $questionModel;
    private $surveyCustomerModel;
    private $surveyCustomerRatingModel;

    public function __construct($pdo)
    {
        $this->pdo = $pdo;
        $this->surveyModel = new Survey($pdo);
        $this->questionModel = new Question($pdo);
        $this->surveyCustomerModel = new SurveyCustomer($pdo);
        $this->surveyCustomerRatingModel = new SurveyCustomerRating($pdo);
    }
}

The above code imports and instantiates our database models. We also imported our Twilio helper classes we will need later on.

Add helper methods

Some helper methods, appUrl() and buildQueryString(), will be added to our TwilioController. The appUrl() method will assist us in generating a complete URL based on the requested URI and the current server environment. The buildQueryString() method will take in an associative array and return a query param string from the array values.

Insert the following method into app/Controllers/TwilioController.php.

public function appUrl($requestUri)
{
    $protocol = (!empty($_SERVER["HTTPS"]) 
                        && $_SERVER["HTTPS"] !== "off") 
                        || $_SERVER["SERVER_PORT"] == 443
                            ? "https://"
                            : "http://";
    $domain = $_SERVER["HTTP_HOST"];
    $url = $protocol . $domain . $requestUri;
    return $url;
}

The above code determines the protocol (http or https), gets the domain name, constructs the full URL and returns the URL.

Then, also in app/Controllers/TwilioController.php, insert the following method:

public function buildQueryString($data)
{
    $queryParams = $data;
    $queryString = http_build_query($queryParams);
    return $queryString;
}

The above code uses PHP's built-in http_build_query function to generate a query params string from an array.

Add the ability to initiate calls

Since our TwilioController will handle all our Twilio actions and routes, we will create a initiateMultipleCalls() method that will call our customers. The /twilio/voice route will be used to handle when they respond. We will also pass the customer's phone number and survey ID as query parameters; they will be used when handling the call.

In our TwilioController, let’s add a method to initiate the calls which will require two parameters: the survey ID and our customers' phone numbers

public function initiateMultipleCalls($survey_id, $phoneNumbers = [])
{
    $twilioSid = $_ENV["TWILIO_SID"];
    $twilioToken = $_ENV["TWILIO_AUTH_TOKEN"];
    $twilioPhoneNumber = $_ENV["TWILIO_PHONE_NUMBER"];
    $twilio = new Client($twilioSid, $twilioToken);
    try {
        foreach ($phoneNumbers as $phoneNumber) {
            $queryString = $this->buildQueryString([
                "survey_id" => $survey_id,
                "phoneNumber" => $phoneNumber,
            ]);
            $call = $twilio->calls->create(
                $phoneNumber, 
                $twilioPhoneNumber, 
                [
                    "url" => $this->appUrl("/twilio/voice?" . $queryString),
                ]
            );
        }
        return "Done";
    } catch (\Exception $e) {
        echo "Error: " . $e->getMessage();
    }
}

With our initiateMultipleCalls() method created, we will add it to the create() method of our SurveyController. We will first import our TwilioController into SurveyController.

use App\Controllers\TwilioController;

Then, our initiateMultipleCalls() method will be added just after the code block for saving the survey and customers' phone numbers. We will pass the survey ID and phone number to the initiateMultipleCalls() method. To achieve this, replace the create() method in the SurveyController with the following definition.

public function create()
{
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $survey_id = $this->surveyModel->create($_POST);
        $questions = $_POST['questions'];
        foreach ($questions as $index => $question) {
            $this->questionModel->create($question, $survey_id);
        }
        $phoneNumbers = $_POST['survey_customers'];
        foreach ($phoneNumbers as $index => $phone) {
            $this->surveyCustomerModel->create($phone, $survey_id);
        }
         $twilioController = new TwilioController($this->pdo);
         $twilioController->initiateMultipleCalls($survey_id, $phoneNumbers);
        $_SESSION['message'] = 'Survey created successfully!';
        header('Location: /survey/create');
        exit;
    }

    ob_start();
    include '../app/views/survey/create.php';
    $content = ob_get_clean();
    include '../app/views/layout.php';
}

With the initiateMultipleCalls() added, our create() method in SurveyController should be like the above code.

Handle when a customer answers

When the customer answers, we need to send them our opening line and add an action URL to gather their feedback on whether or not they want to proceed with the survey. Using the survey ID from the GET request, we will query our database for the opening message for a particular survey.

Add the following voice() method to our TwilioController with an action URL to gather the feedback.

public function voice()
{
    $survey_id = $_GET["survey_id"];
    $phoneNumber = $_GET["phoneNumber"];
    $queryString = $this->buildQueryString([
        "survey_id" => $survey_id,
        "phoneNumber" => $phoneNumber,
        "opener" => 1,
    ]);
    $response = new VoiceResponse();
    $gather = $response->gather([
        "input" => "dtmf",
        "numDigits" => 1,
        "action" => $this->appUrl("/twilio/gather?" . $queryString),
    ]);
    $survey = $this->surveyModel->find($survey_id);
    $gather->say(
        $survey["opening_message"] . " Press 1 to continue, 2 to decline."
    );
    $response->say("We didn't receive any input. Goodbye!");
    header("Content-Type: text/xml");
    echo $response;
}

From the above code, $response->gather() uses an associative array to specify what response we want from our customers when they answer; 'numDigits' => 1 and 'input' => 'dtmf' signified that our customers are required to answer by pressing digits on their phone.

The digit response will be processed by our action URL /twilio/gather, where we also send some data via query params. Using the $this->buildQueryString(), we created a query string containing the survey ID, customer phone number, and a boolean indicating it is the opener.

Add the ability to gather feedback

Let's see how we can gather our customer input during the call. We will start by creating a feedback gathering method named gather(), for our action URL (/twilio/gather) in our TwilioController. It will handle two application states:

  • Opening message response
  • Customer feedback for a question

Opening message response

If it's the opening question, which is indicated by the "opener" key in the $_GET array, we check if the user pressed 1 or 2. Pressing 2 closes the call, while 1 redirects them to our first question via a URL.

Insert the following code in our TwilioController to handle the opening message response.

public function gather()
{
    $digits = null;
    if (isset($_GET["Digits"])) {
        $digits = $_GET["Digits"];
    } elseif (isset($_POST["Digits"])) {
        $digits = $_POST["Digits"];
    }

    $opener = $_GET["opener"] ?? null;
    $survey_id = $_GET["survey_id"] ?? null;
    $phoneNumber = $_GET["phoneNumber"] ?? null;
    $response = new VoiceResponse();

    if ($digits) {
        if ($opener == "1") {
            if ($digits == "1") {
                $queryString = $this->buildQueryString([
                    "survey_id" => $survey_id,
                    "phoneNumber" => $phoneNumber,
                    "questionIndex" => 0,
                ]);
                $response->redirect($this->appUrl("/twilio/question?" . $queryString));
            } elseif ($digits == "2") {
                $response->say("Thank you for your time. Goodbye.");
            }
        }
    }
}

The gather() method retrieves the customer response, i.e., which number they pressed on their phone, then handles if they want to proceed or not. If they proceed, we will redirect them to the first question.

Handle redirect to a question

Our question redirect handler will get our questions from our database for the given survey using the survey ID in the query parameters. We will also get the current question to ask using the question index from our query parameters. When our customer hears our question, our action URL (/twilio/gather) we created earlier will handle their feedback when they answer the question with a digit from 1 - 5.

Add the following question() method to the TwilioController. The method will pick the appropriate question and use the $response->gather() to handle our customers' feedback.

public function question()
{
    $survey_id = $_GET["survey_id"] ?? null;
    $phoneNumber = $_GET["phoneNumber"] ?? null;
    $questionIndex = $_GET["questionIndex"] ?? null;
    $questions = $this->questionModel->findBySurvey($survey_id);
    $question = $questions[$questionIndex];
    $is_last_question = $questionIndex === array_key_last($questions) ? 1 : 0;
    $response = new VoiceResponse();
    $queryString = $this->buildQueryString([
        "survey_id" => $survey_id,
        "phoneNumber" => $phoneNumber,
        "questionIndex" => $questionIndex,
        "is_last_question" => $is_last_question,
        "question_id" => $question["id"],
    ]);
    $gather = $response->gather([
        "input" => "dtmf",
        "numDigits" => 1,
        "action" => $this->appUrl("/twilio/gather?" . $queryString),
    ]);
    $gather->say($question["question"]);
    $response->say("We didn't receive any input. Goodbye!");
    header("Content-Type: text/xml");
    echo $response;
}

Gather customer feedback for a question

Now we'll update the gather() method to handle subsequent customer responses to questions they hear. It will first save the digit they entered, which serves as a rating to the database, and then redirect to the next question. We will also check and close with a message if they are at the last question.

The following code will replace the gather() method of our TwilioController. It will now contain the block to be executed when we are processing a question that is not the opener.

The new code can be seen at the else block of the if ($opener == '1') { statement.

public function gather()
{
    $digits = null;
    if (isset($_GET["Digits"])) {
        $digits = $_GET["Digits"];
    } elseif (isset($_POST["Digits"])) {
        $digits = $_POST["Digits"];
    }

    $opener = $_GET["opener"] ?? null;
    $survey_id = $_GET["survey_id"] ?? null;
    $phoneNumber = $_GET["phoneNumber"] ?? null;
    $questionIndex = $_GET["questionIndex"] ?? null;
    $is_last_question = $_GET["is_last_question"] ?? null;
    $question_id = $_GET["question_id"] ?? null;
    $response = new VoiceResponse();

    if ($digits) {
        if ($opener == "1") {
            if ($digits == "1") {
                $queryString = $this->buildQueryString([
                    "survey_id" => $survey_id,
                    "phoneNumber" => $phoneNumber,
                    "questionIndex" => 0,
                ]);
                $response->redirect($this->appUrl("/twilio/question?" . $queryString));
            } elseif ($digits == "2") {
                $response->say("Thank you for your time. Goodbye.");
            }
        } else {
            $survey_customer = $this->surveyCustomerModel->findByPhoneAndsurvey(
                $phoneNumber,
                $survey_id
            );
            $this->surveyCustomerRatingModel->create([
                "survey_id" => $survey_id,
                "question_id" => $question_id,
                "survey_customers_id" => $survey_customer["id"],
                "phone" => $phoneNumber,
                "rating" => $digits,
            ]);
            if ($is_last_question == "1") {
                $response->say("Thank you for your time. Goodbye.");
            } else {
                $questionIndex++;
                $queryString = $this->buildQueryString([
                    "survey_id" => $survey_id,
                    "phoneNumber" => $phoneNumber,
                    "questionIndex" => $questionIndex,
                ]);
                $response->redirect(
                    $this->appUrl("/twilio/question?" . $queryString)
                );
            }
        }
    } else {
        $response->say("No digits were pressed. Goodbye.");
    }

    header("Content-Type: text/xml");
    echo $response;
}

With the second part of our gather() method, we can now see the complete process for handling our two states: the opener and subsequent question responses. We can now run the full process of creating a survey via the form and initiate phone calls.

View survey and customer feedback

We will need where we can see the surveys we created, our customer phone numbers, the questions, and their feedback. Let's add some methods to query our database for this purpose in our Models directory.

In app/Models/Survey.php add the following method to query the database for all surveys.

public function all()
{
    $stmt = $this->pdo->query("SELECT * FROM surveys");
    return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}

Then in the file app/Models/SurveyCustomerRating.php create a new method named findBySurvey() to get our customer's feedback ratings from our database for a particular survey. Include the following code which gets the customers' ratings and the questions asked for a given survey.

public function findBySurvey($survey_id)
{
    $stmt = $this->pdo->prepare(
        "SELECT *
        FROM survey_customers_rating
        JOIN questions ON survey_customers_rating.question_id = questions.id
        WHERE survey_customers_rating.survey_id = ?"
    );
    $stmt->execute([$survey_id]);
    return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}

Create the UI to view the surveys

We have our SQL queries ready, so we will need methods in our SurveyController to get our queried data, using the methods created prior, into our UI templates.

Create an index() method in our SurveyController, by pasting the code below into the class.

public function index()
{
    $surveys = $this->surveyModel->all();
    ob_start();
    include '../app/views/survey/index.php';
    $content = ob_get_clean();
    include '../app/views/layout.php';
}

This method will get all our surveys, which will be used to generate the content for an index.php view file we will create.

From the above method, using ob_start() before including the index.php file will hold the generated content in an internal buffer. The ob_get_clean() function then captures the buffered content and stores it in the $content variable, which also ends output buffering. Then, our content is inserted into our layout.php file.

Let’s create an index.php view file in app/views/survey directory that will contain an HTML table. Since this file has access to the surveys, we will use php foreach to loop through our survey records, each iteration will return a table row containing the survey attributes. Add the following code to the file.

<div class="mx-auto max-w-7xl p-4 py-10">
    <h1 class="text-xl font-semibold leading-7 text-gray-900 text-center my-4">Surveys</h1>
    <table class="border-collapse table-auto w-full text-sm">
        <thead>
        <tr>
        <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
          Title
        </th>
        <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
          Description
        </th>
        <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
          Opening Message
        </th>
        <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
          Date
        </th>
        <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
        </th>
      </tr>
    </thead>
    <tbody class="">
      <?php foreach ($surveys as $index => $survey): ?>
      <tr class="<?php echo $index % 2 === 0 ? 'bg-gray-50' : '' ?>">
        <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
          <?php echo htmlspecialchars($survey['title']);
            $index; ?>
        </td>
        <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
          <?php echo htmlspecialchars($survey['description']); ?>
        </td>
        <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
          <?php echo htmlspecialchars($survey['opening_message']); ?>
        </td>
        <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
          <?php echo htmlspecialchars($survey['created_at']); ?>
        </td>
        <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
          <a href="/survey/responses/<?php echo $survey['id']; ?>"
            class="text-sm font-semibold leading-6 text-gray-900">
          View Responses
          </a>
        </td>
      </tr>
      <?php endforeach; ?>
    </tbody>
  </table>
</div>

Create the UI to view the feedback responses

From the content in our index.php, every survey in our table has a link generated with the survey ID to view our customers' feedback responses. Let’s create the resulting page by following how our index.php was generated.

We will create a responses() method in our SurveyController to handle the route when we want to see the feedback responses for a given survey. Then, we will create a response.php file in our app/views/survey directory, which will receive the rating data, loop through each rating and render it in a tabular form.

Add the following method to our SurveyController in the app/views/survey directory.

public function responses($id)
{
    $customer_ratings = $this->surveyCustomerRatingModel->findBySurvey($id);
    ob_start();
    include '../app/views/survey/response.php';
    $content = ob_get_clean();
    include '../app/views/layout.php';
}

Create a response.php in our app/views/survey directory and insert the following code.

<div class="mx-auto max-w-7xl p-4 py-10">
    <h1 class="text-xl font-semibold leading-7 text-gray-900 text-center my-4">Responses</h1>
    <table class="border-collapse table-auto w-full text-sm">
        <thead>
            <tr>
                <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
                    Phone
                </th>
                <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
                    Question
                </th>
                <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
                    Rating
                </th>
                <th class="border-b dark:border-slate-600 font-medium p-4 pt-0 pb-3  text-left">
                    Date
                </th>
            </tr>
        </thead>
        <tbody class="">
            <?php foreach ($customer_ratings as $index => $rating): ?>
                <tr class="<?php echo $index % 2 === 0 ? 'bg-gray-50' : '' ?>">
                    <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
                        <?php echo htmlspecialchars($rating['phone']);
                        ?>
                    </td>
                    <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
                        <?php echo htmlspecialchars($rating['question']); ?>
                    </td>
                    <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
                        <?php echo htmlspecialchars($rating['rating']); ?>
                    </td>
                    <td class="border-b border-slate-100 dark:border-slate-700 p-4  text-slate-500 ">
                        <?php echo htmlspecialchars($rating['created_at']); ?>
                    </td>
                </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
</div>

Test the application

To test the application, ensure the application is running, if not start the application by running the following command.

php -S localhost:8000 -t public

Now, you need to make the application publicly accessible over the internet. Do that by running the ngrok command below in a new terminal session or tab.

ngrok http 8000

With the application running and accessible, follow the steps below to create, test, and view the responses for your survey.

  1. Check your terminal for the generated ngrok Forwarding URL, then copy and paste it into your preferred browser.
  2. Navigate to the survey creation page by appending /survey/create to the ngrok URL (e.g., <<ngrok_url>>/survey/create).
  3. Fill out the survey form, including your mobile number in the customer's phone input, and submit it.
  4. You will receive a call. Answer the call to hear the opening message.
  5. Press 1 to accept the survey request
  6. The application will move to the questions you added during the survey creation.
  7. Answer each question with digits 1–5 on your phone. At the last question, the application will respond with a thank you message, and end the call.
  8. Once the call is over, navigate to <<ngrok_url>>/survey to view your created survey and any subsequent ones.
  9. There is a "View Responses" button for each created survey. Clicking it will take you to a page displaying the questions and responses you provided.

Troubleshoot the application

If you are not getting your customer's response, use this Twilio help guide for further troubleshooting.

That's how to create a customer satisfaction survey using Twilio Programmable Voice and PHP

We have seen how to build a customer satisfaction survey using Twilio's Programmable Voice API and PHP. The code repository is available here.

You can reach me, Godwin Agedah, on the following channels: Email: agedah99@gmail.com, Twitter: @GodwinAgedah, and Github: Godwin9911.

Survey icon in the post's main image was created by Uniconlabs - Flaticon.