Handle Symfony Events with Twilio SMS

January 18, 2023
Written by
Reviewed by

Handle Symfony Events with Twilio SMS

Events are an excellent way of building richer, more flexible, and more maintainable applications. Why? Because when events are dispatched, any number of subscribers (often referred to as listeners) can respond asynchronously. Subscribers are not dependent on each other, so they can be added and removed as and when the need arises.

Consider a stereotypical web application, perhaps a CMS of some kind. In that application, a user might be able to register for a new account. When they do that, several processes need to happen. These might include creating one or more database entries for the new user, and sending them a confirmation email so that they know that their account has been created.

In a non-event-aware application, each of these processes would be executed one after another, such as within a controller function. While this can work well, if one of the processes fails, none of the subsequent processes will likely execute, leading to a poor user experience.

When using events, however, each of the respective processes can be encapsulated within a separate class and executed autonomously of any other. Given this, subscribers are easier to test and maintain. They could also be reused throughout the application.

I hope that piqued your curiosity. Because in this tutorial, you'll learn how to use Symfony's EventDispatcher Component to respond to events in a Symfony application by sending SMS messages powered by Twilio's SMS API.

Prerequisites

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

What is Symfony's EventDispatcher Component

Quoting the official documentation:

The EventDispatcher component provides tools that allow your application components to communicate with each other by dispatching events and listening to them. The Symfony EventDispatcher component implements the Mediator and Observer design patterns.

Paraphrasing the documentation a little, here is the essence of what happens when using the component:

  • A listener (PHP object) tells a central dispatcher object that it wants to listen to one or more events;
  • At some point during the application’s lifecycle, one or more of those events are dispatched, passing with them an Event object;
  • The dispatcher notifies (i.e., calls a method on) all listeners of those events, allowing each of them to respond in their own way.

In addition to Listeners, Symfony supports Subscribers. In contrast to Listeners who are told that an event has been dispatched, Subscribers can tell the Dispatcher which events they want to listen to. The code in this tutorial will use Subscribers.

A quick application overview

With the essence of the component in mind, here is how the application you’re going to build will work.

  • A single subscriber, UsageRecordsRetrievedSMSSubscriber, will tell the dispatcher that it wants to listen for the usage.records.retrieved event;
  • When the sole application route is requested, your account usage details for the last month will be retrieved and displayed in JSON format, and the usage.records.retrieved event will be dispatched;
  • UsageRecordsRetrievedSMSSubscriber will then respond to the event, calculating the total cost for those usage records before sending an SMS with that amount;

Check that your development environment meets Symfony’s minimum requirements

Before scaffolding a new Symfony application, use Symfony’s CLI tool to check that your development environment meets Symfony’s minimum requirements, by running the following command in the terminal.

symfony check:requirements

If it does, you will see the following printed to the terminal:

The symfony check:requirements output printed to the terminal

Scaffold a new Symfony application

Next, scaffold a minimal Symfony application, named sms-event-handler, and change into the newly scaffolded application’s directory, by running the following commands.

symfony new sms-event-handler --version="6.2.*"
cd sms-event-handler

With the application scaffolded, check that it works. First, start up the application by running the following commands.

symfony server:start

Then, open http://127.0.0.1:8000 in your browser of choice, where it should look like the screenshot below.

The default Symfony route displayed in Firefox

Now that you know it’s working, in the terminal, press Ctrl+C to stop the application.

Install the required dependencies

Now, it's time to install the three third-party dependencies that the application needs. These are

  1. Twilio’s PHP Helper Library, which reduces the effort required to send SMS with Twilio;
  2. Symfony’s EventDispatcher Component
  3. Symfony’s MakerBundle, which simplifies the creation of the default controller;

To install them, run the following commands in your terminal

composer require symfony/event-dispatcher twilio/sdk
composer require --dev symfony/maker-bundle

Set the required environment variables

The next thing that you need to do is to set four environment variables; these are:

  • Your Twilio Account SID and Auth Token, required for making authenticated requests to Twilio’s APIs
  • Your Twilio phone number to send the SMS from
  • The phone number to receive the SMS from the application

To do that, at the bottom of .env, add the following configuration:

###> Twilio authentication details ###
TWILIO_ACCOUNT_SID="xxxxxxxxxxxx"
TWILIO_AUTH_TOKEN="xxxxxxxxxxxx"
TWILIO_PHONE_NUMBER="xxxxxxxxxxxx"
###< Twilio authentication details ###

###> SMS details ###
SMS_RECIPIENT_NUMBER="xxxxxxxxxxxx"
###< SMS details ###

Screenshot of the Account info panel in the Twilio Console

Next, retrieve your Twilio credentials and phone number. 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 placeholders for TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in .env. Finally, replace the placeholder for SMS_RECIPIENT_NUMBER with your mobile phone number.

Now, it’s time to write some code; not much, just two classes.

Create the Event class

Create a new directory in src named EventSubscriber. Then, in that directory, create a new PHP file named UsageRecordsRetrievedEvent.php and paste the code below into it.

<?php

namespace App\EventSubscriber;

use Symfony\Contracts\EventDispatcher\Event;

class UsageRecordsRetrievedEvent extends Event
{
    public const NAME = 'usage.records.retrieved';

    private array $usageRecords;

    public function __construct(array $usageRecords = [])
    {
        $this->usageRecords = $usageRecords;
    }

    public function getUsageRecords(): array
    {
        return $this->usageRecords;
    }
}

This class extends Symfony\Contracts\EventDispatcher\Event, which is the base class for containing event data. The class constant (NAME) defines the event name to dispatch and which subscribers will subscribe to.

The constructor takes an array of account usage information ($usageRecords) and provides a getter method, getUsageRecords(),  for subscribers to retrieve that information as required.

Create the Subscriber class

Now, for the subscriber class. In src/EventSubscriber, create a new PHP file named UsageRecordsRetrievedSMSSubscriber.php, and paste the code below into the file.

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use NumberFormatter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Twilio\Rest\Client;

class UsageRecordsRetrievedSMSSubscriber implements EventSubscriberInterface
{
    private Client $client;
    private string $smsRecipient;
    private string $smsSender;

    public function __construct(
        Client $client, 
        string $smsSender, 
        string $smsRecipient
    ) {
        $this->client = $client;
        $this->smsSender = $smsSender;
        $this->smsRecipient = $smsRecipient;
    }

    public static function getSubscribedEvents()
    {
        return [
            UsageRecordsRetrievedEvent::NAME => [
                ['usageRecordsRetrieved', -10]
            ],
        ];
    }

    public function usageRecordsRetrieved(
        UsageRecordsRetrievedEvent $event
    ) {
        $usageTotal = $this->getUsageTotal($event->getUsageRecords());
        $usageCount = count($event->getUsageRecords());

        $this->client
            ->messages
            ->create(
                $this->smsRecipient,
                [
                    'body' => sprintf(
                        '%d usage records retrieved. Usage cost: %s.', 
                        $usageCount, 
                        $usageTotal
                    ),
                    'from' => $this->smsSender
                ]
            );
    }

    private function getUsageTotal(array $usageRecords = []): string
    {
        if (empty($usageRecords)) {
            return "Usage total not available.";
        }

        $sum = array_sum(
            array_keys(
                array_column($usageRecords, null, 3)
            )
        );

        $currency = $usageRecords[0][4];

        $fmt = new NumberFormatter('en_US', NumberFormatter::CURRENCY);

        return  $fmt->formatCurrency($sum, strtoupper($currency));
    }
}

The class constructor takes three arguments:

It then defines the getSubscribedEvents() function. This function specifies which events the class responds to, the method to invoke when responding to a given event, and the subscriber priority.

This final point is worth discussing a bit further. As an event can have any number of subscribers, the priority tells the application the order in which the subscribers react to the dispatched event.

Next, the usageRecordsRetrieved() function receives a UsageRecordsRetrievedEvent object ($event). It then uses that information to determine the total cost of sending the messages, formats that amount in the specified currency, and sends it in an SMS using the $client object.

Lastly comes the getUsageTotal() function. This function uses a combination of three, built-in, PHP functions to retrieve the total cost of sending the messages in $usageRecords. These are:

  • array_column: This extracts the cost column from all of the arrays in $usageRecords
  • array_keys: This strips out any information other than the cost
  • array_sum: This sums the extracted values

I found these three built-in functions to be an excellent alternative to for or foreach loops, or using a combination of iterators, such as FilterIterator. Would you have used them as well?

After that, NumberFormatter::formatCurrency() formats the returned value as a currency, so that it reads as you’d expect.

Update the application’s configuration

Now, it’s time to configure the application. Add the following to the bottom of config/services.yaml, in the services section:

    twilio.client:
        class: Twilio\Rest\Client
        autowire: false
        arguments:
            - '%env(resolve:TWILIO_ACCOUNT_SID)%'
            - '%env(resolve:TWILIO_AUTH_TOKEN)%'

    App\EventSubscriber\UsageRecordsRetrievedSMSSubscriber:
        arguments:
            $smsSender: '%env(resolve:TWILIO_PHONE_NUMBER)%'
            $smsRecipient: '%env(resolve:SMS_RECIPIENT_NUMBER)%'

    Twilio\Rest\Client: '@twilio.client'

This configuration adds a service definition for the Twilio Client class, so that it can be provided to UsageDataController during instantiation, and tells the Service Container what to supply to UsageRecordsRetrievedSMSSubscriber for the $smsSender and $smsRecipient constructor parameters.

Create the default controller

It’s now time to create a minimalist controller to retrieve the account usage information and to dispatch the usage.records.retrieved event.

To do that, run the command below.

php bin/console make:controller UsageDataController

This will create a new file in src/Controller, named UsageDataController.php. Replace the file’s existing content with the following code.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\EventSubscriber\UsageRecordsRetrievedEvent;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Twilio\Rest\Client;

class UsageDataController extends AbstractController
{
    private EventDispatcherInterface $eventDispatcher;
    private Client $client;

    public function __construct(
        Client $client, 
        EventDispatcherInterface $eventDispatcher
    ) {
        $this->client = $client;
        $this->eventDispatcher = $eventDispatcher;
    }

    #[Route('/usage-records', name: 'app_usage-records')]
    public function index(): JsonResponse
    {
        $lastMonth = $this->client
            ->usage
            ->records
            ->lastMonth
            ->read([], 20);

        $usageRecords = [];
        foreach ($lastMonth as $record) {
            $usageRecords[] = [
                $record->asOf,
                $record->category,
                $record->count,
                $record->price,
                $record->priceUnit,
            ];
        }

        $this->eventDispatcher->dispatch(
            new UsageRecordsRetrievedEvent($usageRecords),
            UsageRecordsRetrievedEvent::NAME
        );

        return $this->json($usageRecords);
    }
}

The class constructor takes a Twilio Client and an object that implements EventDispatcherInterface. Then, the index() function uses $client to retrieve up to 20 records sent in the last month, storing them in $lastMonth.

It then iterates over the retrieved records and stores several properties in a new array ($usageRecords). These are:

  • asOf: This is the date that the item was sent
  • category: This is the item's category
  • count: This is the number of items sent
  • price: This is how much each item cost
  • priceUnit: The currency in which price is measured, in ISO 4127 format, such as usd, eur, and jpy.

Then, the event is dispatched, and a JSON representation of $usageRecords is returned.

Test that the application works

Now, the code's finished, so it's time to test that the application works. Start the application by running the following command

symfony server:start

Then, with the application running, open http://localhost:8000/usage-records in your browser. You should see all of the retrieved usage records rendered as JSON. A few moments later, you should receive an SMS telling you how many records were retrieved, along with the total cost of sending them, as in the screenshot below.

Example of SMS sent by the application

That's how to handle Symfony Events with Twilio SMS

There is a lot more to the topic than I have covered in this tutorial, so check out the Symfony documentation to learn more. What’s more, have a play with the code and build a range of different subscribers, ones that perhaps use other Twilio services, or different tooling entirely. I’d love to know what you build.

Matthew Setter is a PHP Editor in the Twilio Voices team and a PHP and Go developer. He’s also the author of Mezzio Essentials and Docker Essentials. When he's not writing PHP code, he's editing great PHP articles here at Twilio. You can find him at msetter@twilio.com, and on Twitter, and GitHub.