Send SMS Updates for Background Tasks with Symfony Messenger and Twilio SMS

April 21, 2020
Written by
Alex Dunne
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Send SMS Updates for Background Tasks with Symfony Messenger and Twilio SMS.png

Overtime, APIs and web apps that were once performant can become sluggish and unresponsive. This is often due to the natural progression of a codebase which has evolved to perform more work with an increasing amount of data. This slow down is mostly felt in areas that involve communicating with third-party APIs, report generating, or crunching a lot of data.

Ultimately, this extra time affects how long users have to wait to get a response to their actions.

There are a variety of ways one could reduce these performance issues. For this tutorial we're going to be focusing on processing heavy workloads in the background so that we can respond to users quicker. Specifically, we will learn how to queue background, resource-intensive tasks using Symfony and Symfony's Messenger Component whilst keeping users notified of the work status via Twilio SMS.

Prerequisites

In order to complete this tutorial you will need the following:

Creating a New Symfony Application

To begin we will create a new Symfony project. We'll be using Composer to generate the project for us, so if you don't already have it installed, you can follow the installation instructions from the Composer documentation. Once you have Composer installed run the following command in a terminal:

$ composer create-project symfony/website-skeleton background_sms_notification

We will also install the Twilio PHP SDK and Symfony Messenger Component as we'll need them both later on. Run the following command in a terminal:

$ composer require twilio/sdk symfony/messenger

We will be using secret API keys so we need to ensure they aren't committed to the version control system. By default, Symfony provides a .gitignore file with .env.local as one of the entries. This file is where we will store our secret API keys. Run the following command in a terminal to create this file:

$ touch .env.local

Setting Up the Twilio SDK

Before we can start using the Twilio SDK, we first need to fetch our Twilio credentials. If you haven't already got an account you can create a new account here. Twilio will also provide you with free credits to test the API. Once you have logged in, navigate to the dashboard and you will see your Account SID and Auth Token.

Twilio dashboard

You will also need an active phone number with SMS capabilities. If you don't already have a phone number you can find one here.

We now have all of the data required to communicate with Twilio’s API. Copy your Account SID, Auth Token, and phone number to the .env.local file we created earlier as follows:

# .env.local
TWILIO_SID=
TWILIO_AUTH_TOKEN=
TWILIO_NUMBER=

Building the Form

To simulate a heavy workload we're going to create a simple form that when submitted randomly takes between 10 and 15 seconds to respond. To get started create a new file named HeavyWorkloadController.php in the src/Controller directory and insert the following code:

<?php
// src/Controller/HeavyWorkloadController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class HeavyWorkloadController extends AbstractController
{
    /**
     * @Route("/", name="heavy_work_new")
     * @param Request $request
     * @return Response
     */
    public function new(Request $request)
    {
        $initialData = ['firstName' => ''];

        $form = $this->createFormBuilder($initialData)
            ->add('firstName', TextType::class)
            ->add('save', SubmitType::class, ['label' => 'Generate report'])
            ->getForm();

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
               $data = $form->getData();
                                                       
            // sleep is used to simulate an actual report being generated
            sleep(random_int(10, 15));
             
            return $this->redirectToRoute('heavy_work_show', [
                'firstName' => $data['firstName'],
            ]);
        }

        return $this->render('new.html.twig', [
            'form' => $form->createView()
        ]);
    }

    /**
     * @Route("/complete", name="heavy_work_show")
     * @param Request $request
     * @return Response
     */
    public function show(Request $request)
    {
        return $this->render('show.html.twig', [
            'firstName' => $request->query->get('firstName')
        ]);
    }
}

​You will need to create the new and show templates as outlined above. Add a new file named new.html.twig to the templates directory and insert the following code:

{# templates/new.html.twig #}
{{ form(form) }}

Additionally, create a file named show.html.twig and insert the following code:

{# templates/show.html.twig #}
<p>
    Hi {{ firstName }}. The report has been generated!
</p>

Now navigate to your local site by running symfony server:start in your terminal. You'll see a single input with a submit button. Type in your name and press submit and you'll see that it takes a considerable amount of time to respond. This may be reminiscent of other web apps you have used or have built.

Gif of request delayed

Background Resource Intensive Tasks

We're now at a point where we have highlighted a part of our application that we can offload to the background, rather than making the user wait for a response. In order to fix this, we can use Symfony's Messenger Component and Redis to add the work to a queue to be processed in the background.

The Symfony Messenger component consists of two main elements: the message class and the message handler class. The message class is a container for the data that we want to pass to our background worker. The data that is stored in this class must be serializable so that the data can be transferred correctly. The message handler class is responsible for consuming the message and performing a task.

First, let's create a message class. Create a new directory named Message in the src directory. Inside the Message directory, create a new file named GenerateReport.php. Replace the contents of the file with the following code:

<?php
// src/Message/GenerateReport.php

namespace App\Message;

class GenerateReport
{
    /** @var string $firstName */
    private $firstName;

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

    /**
     * @return string
     */
    public function getFirstName(): string
    {
        return $this->firstName;
    }
}

Now we need to create the message handler. Create a new directory named MessageHandler in the src directory. Inside the MessageHandler directory, create a new file named GenerateReportHandler.php. Replace the contents of the file with the following code:

<?php
// src/MessageHandler/GenerateReportHandler.php

namespace App\MessageHandler;

use App\Message\GenerateReport;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;

class GenerateReportHandler implements MessageHandlerInterface
{
    public function __invoke(GenerateReport $report)
    {
        sleep(random_int(10, 15));
     
         // Print in the console so we can confirm the message was handled correctly
         var_dump('Report generated');
    }
}

The __invoke method is a Magic Method. This function is called when an instance of the class is called as a function.

You'll notice that the inside of the __invoke function body is the same sleep call we used in the HeavyWorkloadController new action. We're now going to update the controller action by dispatching a GenerateReport message. To do this, open the HeavyWorkloadController file and replace with existing sleep like so:

// src/Controller/HeavyWorkloadController.php

// ...

if ($form->isSubmitted() && $form->isValid()) {
    $data = $form->getData();
                                               
           $this->dispatchMessage(new GenerateReport($data['firstName']));

    return $this->redirectToRoute('heavy_work_show', [
        'firstName' => $data['firstName'],
    ]);
}

// ...

If you submit the form again you'll notice that it still takes a long time for the request to send a response. This is because, by default, Symfony handles the message as soon as it is dispatched. To dispatch the message so that it is handled asynchronously we need to configure a transport. The Messenger component supports many transports out of the box, but in our case, we're going to use Redis.

To get started we need to set a MESSENGER_TRANSPORT_DSN environment variable. This variable should point to a Redis instance and database. Open the .env.local file we created earlier and append the following line:

MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages

We now need to configure the transport in our yaml config. Add the following to the config/packages/messenger.yaml file:

# config/packages/messenger.yaml

framework:
    messenger:
        transports:
             async: '%env(MESSENGER_TRANSPORT_DSN)%'

        routing:
             'App\Message\GenerateReport': async

The configuration above first creates a transport named async using the DSN provided via the environment variables. Additionally, we inform Symfony that we want all App\Message\GenerateReport messages to be sent through the async transport rather than being handled immediately.

It's important to note that any newly dispatched messages are added to a queue, but currently are not being processed. To consume and process the messages, open a terminal and run the following command:

$ php bin/console messenger:consume async

NOTE: If you change contents of the src/MessageHandler/GenerateReportHandler.php class you'll need to restart the consumer.

Now navigate back to your local site and resubmit the form. This time you'll notice that the response is instant! That is because we've moved the heavy work out of the request-response cycle into a queue so that it can be processed in the background. If you look at your terminal that is running the messenger:consume command you should see "Report generated!"

Keeping Users Informed of Their Report Progress

We have now moved the resource-intensive task of generating a report to the background so that our application is fast once again. However, we have lost the ability to inform the user that their report has been generated. This is where Twilio comes in. Using Twilio's SMS functionality we can send notifications to the user informing them that the report generation has started, and also when the report has been generated.

First, we need to update our form to capture the phone number the user wants us to send the SMS messages to. Open the HeavyWorkloadController.php file and add a new field to the form like so:

// src/Controller/HeavyWorkloadController.php

// ...

public function new(Request $request)
{
    $initialData = ['firstName' => '', 'phoneNumber' => ''];

    $form = $this->createFormBuilder($initialData)
        ->add('firstName', TextType::class)
            ->add('phoneNumber', TextType::class)
        ->add('save', SubmitType::class, ['label' => 'Generate report'])
        ->getForm();

// ...

Thanks to Symfony's form twig function that we used in the templates/new.html.twig file, this phone number field will be automatically rendered for us.

Secondly, we need to pass the provided phone number to the GenerateReportHandler via the GenerateReport message. To accomplish this, we need to update the GenerateReport message class to also hold the phone number. Open the GenerateReport.php file and add a phone number field like so:

<?php
// src/Message/GenerateReport.php

namespace App\Message;

class GenerateReport
{
    /** @var string $firstName */
    private $firstName;

    /** @var string $phoneNumber */
    private $phoneNumber;

    public function __construct(string $firstName, string $phoneNumber)
    {
        $this->firstName = $firstName;
        $this->phoneNumber = $phoneNumber;
    }

    /**
     * @return string
     */
    public function getFirstName(): string
    {
        return $this->firstName;
    }

    /**
     * @return string
     */
    public function getPhoneNumber(): string
    {
        return $this->phoneNumber;
    }
}

We can then pass the phone number to the GenerateReport object that we created in the HeavyWorkloadController like so:

// src/Controller/HeavyWorkloadController.php
namespace App\Controller;

use App\Message\GenerateReport;
// ...

if ($form->isSubmitted() && $form->isValid()) {
        $data = $form->getData();
                                               
        $this->dispatchMessage(new GenerateReport($data['firstName'], $data['phoneNumber']));

    return $this->redirectToRoute('heavy_work_show', [
        'firstName' => $data['firstName'],
    ]);
}                                           
//...

Our GenerateReportHandler now has the phone number that the user wants us to send the notifications to. However, we first need to configure a Twilio client so that we can send the SMS messages.

To do this we're going to configure a Twilio API client with the Account SID and Auth Token we added to our .env.local file earlier. We're also going to inject the TWILIO_NUMBER environment variable as a parameter to the GenerateReportHandler class via Symfony's named arguments. Add the following service definition to your config/services.yaml file:

# config/services.yaml

services:
   # ...

        App\MessageHandler\GenerateReportHandler:
        arguments:
            $fromNumber: '%env(TWILIO_NUMBER)%'

   twilio.client:
       class: Twilio\Rest\Client
       arguments: ['%env(TWILIO_SID)%', '%env(TWILIO_AUTH_TOKEN)%']
  
   Twilio\Rest\Client: '@twilio.client'

​With our Twilio client configured, we can now update our GenerateReportHandler to use this client to send the SMS messages to the provided phone number. Open the GenerateReportHandler class and replace it with the following code:

<?php
// src/MessageHandler/GenerateReportHandler.php

namespace App\MessageHandler;

use App\Message\GenerateReport;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Twilio\Rest\Client;

class GenerateReportHandler implements MessageHandlerInterface
{
    /** @var Client $twilio */
    private $twilio;

    /** @var string $fromNumber */
    private $fromNumber;

    public function __construct(Client $twilio, string $fromNumber)
    {
        $this->twilio = $twilio;
        $this->fromNumber = $fromNumber;
    }

    public function __invoke(GenerateReport $report)
    {
        $toName = $report->getFirstName();
        $toNumber = $report->getPhoneNumber();

        $this->twilio->messages->create($toNumber, [
            'from' => $this->fromNumber,
            'body' => "Hi $toName! Your report has begun processing"
        ]);

        sleep(random_int(10, 15));

        // Print in the console so we can confirm the message was handled correctly
        var_dump('Report generated!');

        $this->twilio->messages->create($toNumber, [
            'from' => $this->fromNumber,
            'body' => "Hi $toName! Your report has finished processing"
        ]);
    }
}

Testing

Restart your consumer so that it reflects the new code changes. Navigate back to your local site and resubmit the form with your phone number.

You should receive the initial message almost immediately, followed by the completed report message 10 to 15 seconds later.

Text message screenshot

Conclusion

Congratulations! You have successfully used Symfony, Symfony’s Messenger Component, and Redis to implement a queue allowing you to background an expensive task. You also used Twilio’s Programmable SMS to keep your users updated with their report generation progress.

If you want to extend this tutorial, I recommend trying different transport types like AMPQ or Doctrine or use SendGrid to notify your users.

Alex Dunne is a Software Engineer based in Birmingham, UK. He loves experimenting with new technology to further develop a well-rounded skill set.