Build a Halloween SMS App with PHP and Twilio's Programmable Messaging API

October 27, 2022
Written by
Reviewed by

Build a Halloween SMS App with Twilio's Programmable Messaging API

Halloween. It's the second-most celebrated festival in the US after Christmas!

People of all ages dress up as ghosts, ghouls – often the scarier the better. Pumpkins are carved with spooky faces. And kids go trick-or-treating, coming home with so many sweets and candies that you'd think their tummies will explode.

It's a wonderful time of year and a fascinating tradition. Let's join in the festivities, and build a small web application that can SMS a Halloween-themed image stored in a DigitalOcean Space to your friends and family using Twilio's Programmable Messaging API.

If you want to trick them, you can send a scarier image and a scary message. If you want to treat them, you can send them a friendly image along with a fun, light-hearted message.

Here's what the app will look like when you're finished.

The complete Halloween-themed web app

Sounds good? Let's begin.


To follow this tutorial you need the following components:

Create the project's directory structure

The first to do is to create the project's directory structure and switch into it, by running the commands below.

mkdir -p halloween-sms-sender/{public/css,src/HappyHalloween/Filter,templates}
cd halloween-sms-sender

If you're using Microsoft Windows, run the following commands instead.

mkdir halloween-sms-sender\public\css
mkdir halloween-sms-sender\templates
cd halloween-sms-sender

The new directory structure will look as follows:

├── public
│   └── css
├── src
│   └── HappyHalloween
│           └── Filter
└── templates

It's a minimalist version of the typical directory structure you'd see in most modern PHP apps, especially in Symfony and Laravel. index.php, the core PHP file for the application, will be stored in the public directory. The CSS file, styles.css, will be stored in public/css. And the application's Twig template files will be stored in the templates directory.

Install the required dependencies

With the project's directory structure created, it's time to install the seven required dependencies. These are:

laminas-inputfilterlaminas-inputfilter combines two packages, laminas-validator and laminas-filter. It simplifies the effort required to validate and filter information, such as user input, query and post information.
PHP DotenvAs I do so commonly, in most of the apps that I build for my Twilio tutorials (and other apps), I use PHP Dotenv to keep sensitive configuration details out of the code (and version control).
PHP-DI Slim BridgeThis package configures Slim to work with the PHP-DI container.
Slim PSR7In addition to SlimPHP, we're using this library to integrate PSR-7 into the application. It's not strictly necessary, but I feel it makes the application more maintainable and portable.
Slim Twig ViewWe're using this package to render view content using the Twig templating engine; such as the forms to request a verification code, and upload an image and the simpler views, such as the success output. You could use template engines, such as Plates and Blade, but Twig is one of the oldest and most powerful, while still being pretty straightforward to use. You may have noticed that Twig isn't in this list. This is because Slim Twig View requires it as a dependency.
SlimPHPNaturally, if we're basing the application on SlimPHP, then we have to make it available.
Twilio’s PHP Helper LibraryAs we're communicating with Twilio, we'll use this package to reduce the effort required.
Spaces-APIThis package simplifies accessing the Digital Ocean Space.

To install them, run the command below in your terminal, in the top-level directory of the project.

composer require --with-all-dependencies \
    laminas/laminas-inputfilter \
    php-di/slim-bridge \
    slim/psr7 \
    slim/slim:"4.*" \
    slim/twig-view \
    sociallydev/spaces-api \
    twilio/sdk \

If you're using Microsoft Windows, replace the backslash character () with a caret (^).

Add src to Composer's autoloader

To ensure that the class that you'll create, in src/HappyHalloween/Filter, towards the end of the tutorial will be autoloaded, you next need to add a PSR-4 autoloader to composer.json.

To do that, add the JSON below after the require element to composer.json.

"autoload": {
    "psr-4": {
        "HappyHalloween\\": "src/HappyHalloween"

Then, run the following command in the terminal.

composer dump-autoload

Set the required environment variables

The next thing that you need to do is to set four environment variables, which the application requires. These are:

  • Your Twilio Account SID, Auth Token, and phone number
  • The base URL of your DigitalOcean Space.

To do this, first, create a new file named .env in the top-level directory of the project.

Set your Twilio credentials

Start off by retrieving and setting your Twilio credentials. To do that, paste the code below into .env.

# Twilio credentials

Next, retrieve your Twilio credentials and phone number, so that the code can make authenticated requests to Twilio's Programmable SMS API and knows which phone number to send the SMS from.

Account Info section of the Twilio Console

To do that, from the Twilio Console's Dashboard, copy your Account SID, Auth Token, and phone number and paste them in place of the respective placeholder values (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER) in .env. You'll set the IMAGE_BASE_URL in the next section.

Set your DigitalOcean Space details

Next, it's time to set the DigitalOcean (DO) Space's details.

Add the following code to the bottom of .env.

# DigitalOcean details

The settings panel for a DigitalOcean Space

Log in to your DigitalOcean account and navigate to your empty Space, Replace the placeholder for SPACE_NAME in .env with the Space's name.

Next, click the Settings tab and retrieve the Space's region. This is the text before "" in the Endpoint field. Replace the placeholder for SPACES_REGION with this value.

Finally, you need to create a Spaces access key and secret. To do that, click API in the left-hand side navigation bar. Then, in the "Spaces access key" section, on the right-hand side, click Generate New Key.

Create a new Spaces access key

After that, enter a name for the new key, such as "halloween-sms-app", and click Enter. You'll then see a key and a secret. Copy them in place of SPACES_KEY and SPACES_SECRET respectively.

A newly created Spaces access key

These are only visible once. If you reload the page, you'll have to regenerate them to access them again.

Upload the Halloween images to your DigitalOcean Space

Then, you need to upload Halloween images to your file host. I've created a small image


SPACES_SECRET="xxxxxxxxxxxx"pack which you can use. However, feel free to use a pack of your own if you'd prefer.

A DigitalOcean Space with no files

With your images at the ready, click Upload Files on the upper right-hand side and pick the images to upload or drag and drop them onto the Space.

Set uploaded files to be public before uploading them to the DigitalOcean Space

You'll then see an Upload Files dialog appear with the images ready to be uploaded. Before uploading them, above the images list set the files to be Public. If you leave them as Private they won't be publicly accessible.

With that change made, press the Upload Files button at the bottom of the dialog.

Showing files in a DigitalOcean Space

Allowing for the speed of your internet connection, in under a minute you'll see the files uploaded to your Space.

Download the CSS file

Next, you need to download the CSS file to public/css, keeping its existing name of styles.css. That way, the project will look like the screenshots throughout the tutorial.

Write the code

With all of the supporting details in place, it's now time to write the application's code. Start by creating a new file in the public directory named index.php. In that file, paste the following code.


use DI\Container;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

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

$container = new Container;
$app = AppFactory::create();

    function (Request $request, Response $response, array $args) {
        $response->getBody()->write('Hello World!');
        return $response;


The code imports the required classes, then initialises an empty Dependency Injection (DI) container, $container, which is then passed to the initialisation of a new Slim\App object$app.

Then, the default route is added to the application. The route only supports GET requests. It sets 'Hello World' as the body of the response before returning the response.

Finally, $app-run() is called, which starts the application.

There is a lot more to add, but I didn't want to overwhelm you by having you implement it all at once. Rather, you're going to progressively build the application in stages.

Test that the application works

Now, test that everything is in place. Start the application by running the code below.

php -S -t public

Then, open http://localhost:8008 in your browser. It should look like the following screenshot.

The essential version of the app running in a web browser

If the site looks as expected, stop the application by pressing Ctrl+c. It's not much to look at, but it works.

Load the environment variables

The first thing to do is to load the environment variables that you created earlier in .env. To do that, add the code below at the top of public/index.php, immediately after the require statement.

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');

If you're not familiar with PHP dotenv yet, the code imports the four variables in .env in the project's top-level directory into PHP's $_ENV and $_SERVER Superglobals.

Add a Twilio Client service

Next, you're going to define a service that provides a Twilio Client object so that the application will be able to interact with Twilio's Programmable SMS API. To do that, add the code below to public/index.php immediately after the call to $container = new Container;.

$container->set(Client::class, function (): Client {
    return new Client(

The service name is the fully-qualified class name of the Client class and returns a Client object, which was instantiated with your Twilio Account SID and Auth Token.

Add a Twig service

Next up, you need to add a Twig object to the request, so that route handlers can retrieve it from the request and build response bodies using Twig templates stored in the templates directory.

To do that, add the code below to public/index.php immediately after the call to $app = AppFactory::create();

        __DIR__ . '/../templates/',
        ['cache' => false]

If you're not familiar with Twig, it's a template engine for PHP. The reason for using it is because templates often require significantly less effort to create than hand-crafted, interpolated PHP strings and can get messy over time.

Now, add the following use statements to the top of the file.

use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;

Finally, overwrite the body of the default route to match the version below.

    function (Request $request, Response $response, array $args) {
        $view = Twig::fromRequest($request);
        return $view->render($response, 'default.html.twig', []);

In the new version of the default route, the Twig object is retrieved from the request. Then, the response body is created by rendering the contents of templates/default.html.twig, before returning the response.

Add a service for the Halloween images

The next thing to do is to add a service containing the Halloween images. To do this, add the code below to public/index.php, immediately after the call to $container = new Container;.

$container->set('images', function (): array {
    $space = (
        new Spaces(

    return (
        new SpaceFilenamesFilter(

If you used your own image pack, don't forget to update the list of images.

The code creates a service named images that retrieves a new Space object that has access to your DigitalOcean Space. Internally, it uses an Amazon S3 client to interact with the Space, as Spaces are fully S3-compatible.

The connection is authenticated using the Spaces key (SPACES_KEY) and secret (SPACES_SECRET) which you set in .env earlier, and determines the Space to connect to with SPACE_NAME. A list of all files in the Space are returned which are then filtered by SpaceFilenamesFilter::filter().

Add the SpaceFilenamesFilter class

Now, it's time to define the SpaceFilenamesFilter class. To do that, in src/HappyHalloween/Filter/ create a new file named SpaceFilenamesFilter.php. Then, in that file, add the code below.



namespace HappyHalloween\Filter;

use Laminas\Filter\FilterChain;
use Laminas\Filter\Word\DashToSeparator;
use Laminas\Filter\Word\UnderscoreToSeparator;

class SpaceFilenamesFilter
    private string $baseURL;

    public function __construct(string $baseURL)
        $this->baseURL = $baseURL;

    public function filterFilenames(array $files): array
        $items = [];

        foreach ($files['files'] as $file) {
            $filename = $file->filename;
            $items[] = [
                'name' => $this->getName($filename),
                'label' => $this->stripFileExtension($filename),
                'image' => $this->getImagePath($filename)

        return $items;

    protected function getName(string $name): string
        $filterChain = new FilterChain();
            ->attach(new DashToSeparator())
            ->attach(new UnderscoreToSeparator("'"));

        return ucwords($filterChain->filter($this->stripFileExtension($name)));

    protected function stripFileExtension(string $name): string
        return basename($name,'.' . pathinfo($name)['extension']);

    protected function getImagePath(string $filename): string
        return sprintf('%s/%s', $this->baseURL, $filename);

The core method, filterFilenames() accepts an array of files in the DO Space, and iterates over them, and creates an array containing three keys, name, label, and image, for each one.

The values of each are described in the table below.

nameThis is the file's name, converted to read in a more human-readable way. Any dashes in the file's name are converted to spaces, and underscores are converted to commas, and the file's extension is removed. Finally, the first letter of each word in the name is uppercased. For example, if a file had a filename "halloween-jack-o_lantern.png" its name key would have the value "Halloween Jack O'Lantern".
labelThe label is the file's name with the extension removed.
imageThis is the absolute URI to the file.

Add a service to filter and validate user input

The next thing to do is to add a service providing a laminas-inputfilter object. To do this, add the code below to public/index.php immediately after the call to $container = new Container;.

$container->set(InputFilter::class, function(): InputFilter {
    $image = new Input('image');
        ->attach(new NotEmpty());
        ->attach(new StringTrim())
        ->attach(new StripTags());

    $message = new Input('message');
        ->attach(new NotEmpty())
        ->attach(new StringLength(['max' => 320]));
        ->attach(new StringTrim())
        ->attach(new StripTags());

    $phoneNumber = new Input('phone_number');
        ->attach(new Regex(['pattern' => '/^\+[1-9]\d{1,14}$/']));
        ->attach(new StringTrim())
        ->attach(new StripTags());

    $inputFilter = new InputFilter();

    return $inputFilter;

All good applications filter and validate external input – especially user input. This service initialises an InputFilter object which has three inputs; one named image, one named message, and one named phone_number. Validation and filter rules are defined on those inputs, and the inputs are added to the InputFilter object.

Here are the validation and filter rules for each input:

InputValidation and Filter Rules
imageThe image name cannot be empty. The value entered will be trimmed and all tags will be stripped.
messageThe message cannot be empty nor longer than 320 characters. The value entered will be trimmed and all tags will be stripped.
phone_numberThe phone number must match the regular expression: ^\+[1-9]\d{1,14}$, Twilio's official E.164 regular expression. The value entered will be trimmed and all tags will be stripped.

If this is your first time encountering laminas-inputfilter, I strongly encourage you to explore the laminas-inputfilter documentation to familiarise yourself with the package.

Update the default route

Next, it's time to update the default route. To do that, in public/index.php update the call to $app->get() to match the following.

$app->map(['GET','POST'], '/',
    function (Request $request, Response $response, array $args)
        $data = [];

        if ($request->getMethod() === 'POST') {
            $inputFilter = $this->get(InputFilter::class);
            if (! $inputFilter->isValid()) {
                $data['errors'] = $inputFilter->getMessages();
                $data['values'] = $inputFilter->getValues();
            } else {
                $twilio = $this->get(Client::class);
                            "body" => $inputFilter->getValue('message'),
                            "from" => $_SERVER['TWILIO_PHONE_NUMBER'],
                            "mediaUrl" => [

                return $response
                    ->withHeader('Location', '/thank-you')

        $data['images'] = $this->get('images');
        $view = Twig::fromRequest($request);

        return $view->render($response, 'default.html.twig', $data);

The updated route definition now accepts both GET and POST requests. If a POST request was made to the route, the InputFilter service is retrieved from the DI container and used to filter and validate the request's POST data.

If the supplied POST data does not satisfy the validation rules, then the validation errors and POST data are stored as template variables, which Twig renders into templates/default.html.twig.

If the supplied POST data does satisfy the validation rules, the Twilio Client is retrieved from the DI container and an SMS containing the form's message and body is sent to the specified phone number.

After that, the user is redirected to the "thank you" route (/thank-you) using a 302 redirect.

Feel free to update the code to handle exceptions when sending the SMS. I've not done so to keep the code as small as possible.

Import the required classes

Finally, update the imports list at the top of public/index.php to match the list below.

use DI\Container;
use HappyHalloween\Filter\SpaceFilenamesFilter;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Filter\StringTrim;
use Laminas\Filter\StripTags;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\NotEmpty;
use Laminas\Validator\Regex;
use Laminas\Validator\StringLength;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
use SpacesAPI\Spaces;
use Twilio\Rest\Client;

Create the default route's template

The next thing to do is to create the default route's template. To do that, create two new files in the templates directory: base.html.twig and default.html.twig.

The first template provides content common to both the default route's template and the "thank you" route which you'll create later. The second template provides content specific to the default route.

Effectively, the route-specific or "child" templates extend the base template or "parent" template, implementing a Two-Step View.

In templates/base.html.twig, paste the following code.

<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8">
    <title>{% block title %}{% endblock %}Halloween Trick-or-Treat Sender</title>
    <link rel="stylesheet" href="/css/styles.css">
    <h1>Halloween Trick-or-Treat Sender</h1>
<main id="app">
    {% block content %}
    {% endblock %}
    <div class="copyright">copyright &copy; Matthew Setter & Twilio 2022.</div>

The head element sets the page's character set, title, and stylesheet.

Note the use of {% block title %}{% endblock %} in the title tag. If you're not familiar with Twig blocks, this is a named element (title) that can be overridden in child templates.

A named block (content) is also used within the main tag. This block will be replaced with the rendered content of the route-specific template.

Now, in templates/default.html.twig, paste the following code.

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

{% block content %}
    <section class="pt-8" id="instructions">
        <h2>Want to Scare Your Friends on Halloween?</h2>
        <div class="lead">
            <p>Prank them with this Halloween image sender. All you need to do is:</p>
                <li>Pick an image</li>
                <li>Write a scary message</li>
                <li>Enter your friend's phone number</li>

    <form name="halloween-trick-or-treat"
        {% if errors is defined %}
        <div class="error text-center text-xl mb-8">
            There are one or more issues with the information you supplied.
        {% endif %}

        <div id="pick-an-image">
            <h2>1. Pick Your Image</h2>
            {% if errors.image is defined %}
                <div class="error">Please pick an image</div>
            {% endif %}
            <div class="images-wrapper">
                {% for image in images %}
                    {% if values.image == image.label %}
                    class="image-container selected"
                    {% else %}
                    {% endif %}
                <input type="radio"
                    id="pick-an-image-{{ image.label }}"
                    value="{{ image.label }}"
                    {% if values.image == image.label %}
                    {% else %}
                    {% endif %}
                <label for="pick-an-image-{{ image.label }}">
                    <img src="{{ image.image }}"
                        alt="{{ }} image"
                <div class="image-label">{{ image.label }}</div>
            {% endfor %}

    <div id="write-your-message">
        <h2>2. Write Your Message</h2>
        {% if errors.message is defined %}
            <div class="error">Please write a message for your friend</div>
        {% endif %}
        <textarea name="message"
            placeholder="Write your spooky message"
        {% if errors.message is defined %}
        {% endif %}
        >{{ values.message }}</textarea>
        <div class="mt-1 pl-1 text-slate-600">The message can be up to 320 characters in length.</div>

    <div id="choose-your-friend">
        <h2>3. Choose Your Friend</h2>
    {% if errors.phone_number is defined %}
    <div class="error">Please enter your friend's phone number in E.164 format (eg., +14155552671).</div>
    {% endif %}
    <div class="choose-your-friend-container">
        for="phone-number">Your Friend's Phone Number:</label>
            <input type="tel"
                placeholder="ex. +14155552671"
                {% if errors.phone_number is defined %}
                {% endif %}
                value="{{ values.phone_number }}">
            <div class="mt-1 pl-1 text-slate-600">
                The phone number must be in <a href="">E.164 format</a>.
<div class="mt-6">
    <input type="submit"
        value="Send Your Message">
    <input type="reset"
{% endblock %}

There's a bit to step through, so please bear with me. The page is composed of two sections:

  1. A header that provides the instructions for using the application
  2. A form that contains the fields to fill out and submit.

Let's skip to the form, as the header's pretty self-explanatory. It's comprised of four sections:

  • One where you pick the image to send
  • One where you write your message
  • One where you provide your friend's phone number
  • One containing the submit and reset buttons; in a production version, you'd include a CSRF token as well.

The section containing the images is built by iterating over a list of images passed to the template, named images. Each image is rendered with a radio button, so that only one image can be chosen, and a label containing the image's name.

A sample of how the Halloween-themed app looks on the desktop and on a mobile device

The CSS Grid layout is used to render the images four columns wide on larger displays and one to two columns wide on smaller displays.

The Halloween-themed app"s form

The sections for writing a message and specifying the phone number include textarea and input elements, along with some help text and placeholders, to let the user know what format the phone number should be in and the maximum length of the message.

Like the images section, they will render an error message if the information provided isn't in the expected format or missing.

Now, let's start refactoring the basic application and turn it into the Halloween-inspired Trick-or-Treat image sender.

Add a "thank you" route

Next up, it's time to add the "thank you" route, where users will be redirected after successfully submitting the form. To do that, in public/index.php after the call to $app->map() add the following code.

    function (Request $request, Response $response, array $args)
        return Twig::fromRequest($request)
            ->render($response, 'thank-you.html.twig', []);

The code adds a route which only supports GET requests to /thank-you. When requested, it renders templates/thank-you.html.twig and returns it as the response's body.

Create the "thank you" route's template

There's one last thing to do, which is to add the "thank you" route's template. To do that, in the templates directory create a new file named thank-you.html.twig. In it, add the following code.

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

{% block title %}Thank You | {% endblock %}

{% block content %}
<section class="pt-8">
        <p class="text-2xl">
            Your Halloween image is on its way to your friend. 
            <a href="/">Want to scare another friend?</a>
{% endblock %}

As with templates/default.html.twig, it extends templates/base.html.twig. It prepends "Thank You | " to the page title, and sets the page's core content to be a confirmation message that the submission was successful. It also provides a link so that the user can send an image to another friend, should they want to.

Test that it works

Now that the application's been built, it's time to use it.  Start the application by running the code below.

php -S -t public

Then, open http://localhost:8008 in your browser of choice. It should look like the image below.

The Halloween-inspired SMS image sender app running in Firefox

Pick an image, add a message, enter your friend's phone number, and click "Send Your Message". If successful, you should be redirected to the "Thank You" route, which looks like the screenshot below.

The thank you page showing that the image was successfully sent.

That's how to build a Halloween inspired SMS image sender

Now you can now SMS Halloween-inspired images to your friends and family. Please don't go overboard, though. While fun, please be considerate and don't spam people.

I hope you've seen just how much fun you can have with SMS' and Twilio's Programmable Messaging API. I strongly encourage you to dive into the documentation to learn more about what you can do, as well as to play with the app's code, which is available on GitHub, to improve it.

I'd love to see what you build!

Matthew Setter is the PHP Editor in the Twilio Voices team and a PHP and Go developer. He’s also the author of Deploy With Docker Compose. When he’s not writing PHP code, he’s editing great PHP articles here at Twilio. You can find him at, Twitter, GitHub, and LinkedIn.

Image credits