How to Track Opt-Outs in PHP

October 02, 2026
Written by
Reviewed by

How to Track Opt-Outs in PHP

Tracking when customers opt-out of communications with you is essential to not running afoul of privacy legislation, such as the Australian Spam Act 2003 and the CAN-SPAM Act 2003.

It's also essential for ensuring that customers have an excellent experience with your business or organisation. Why? Because you respect your customers' wishes if they no longer wish to receive notifications from you.

How do customers tell you this? By sending an opt-out keyword, such as "START" or "STOP", as the body of the SMS when replying to an SMS from you. But, how do you track this so that you respect their choice?

Gladly, Twilio makes tracking customer opt-outs almost trivial through its Incoming Message Webhook and Event Streams features. Both of these will include the OptOutType parameter in their requests to your application if the SMS' body is an opt-out keyword.

If this is something that you want to add to your PHP applications, by the end of this tutorial you're going to learn how. Then, you won't attempt to communicate with customers when you shouldn't.

Application architecture

The application, based on the Twilio Slim Base project, has only one route (/). Twilio will send incoming message webhook and event streams requests to the route after receiving an SMS from your customers.

If the SMS' body is an opt out keyword, such as "START", "STOP", or "HELP", it will be recorded in the application's database.

While Twilio already handles standard opt-out keywords, and blocks further messages being sent to the caller, recording opt-out preferences in the application lets you take more advanced actions, such as keeping your CRM in sync, as and when required.

The application's functionality stops at this point. However, in a production application, you could query the information in the database to determine if a customer had opted out, before attempting to communicate with them.

If you attempt to message a user who has opted out, you'll encounter a 20610: Attempt to send to unsubscribed recipient error.

Prerequisites

To follow along with this app, you'll need the following:

Build the app

Now, it's time to start building the app. Thanks to it being based on the Twilio Slim project, there won't actually be a lot that you need to do.

Step 1: Bootstrap a base PHP project

Start off by running the commands below to bootstrap a new PHP project and change into the newly generated project's top-level directory.

composer create-project settermjd/twilio-slim-base-project php-opt-outs-tracker
cd php-opt-outs-tracker

Step 2: Add the required dependencies

Now, you need to install a few dependencies:

To install them (as always) use Composer:

composer require laminas-config-aggregator laminas-servicemanager phpdb-sqlite

Step 3: Add the ability to track opt-outs

Now, it's time to add the required functionality. To do that, start by updating the handleWebhook() function in src/Application.php to match the following version.

public function handleWebhook(
    ServerRequestInterface $request,
    ResponseInterface $response,
): ResponseInterface {
    $requestData = (array) $request->getParsedBody();
    $optOutType = array_first(
        array_filter(
            $requestData,
            function ($k) {
                return strcasecmp((string) $k, "optouttype") === 0;
            },
            ARRAY_FILTER_USE_KEY,
        ),
    );
    if (
        $optOutType !== null
        && in_array($optOutType, self::ALLOWED_OPT_OUT_TYPES)
    ) {
        $table = $this->app
            ->getContainer()?
            ->get(TableGatewayInterface::class);
        assert($table instanceof TableGatewayInterface);
        $table->insert(
            [
                'account_sid'    => $requestData['AccountSid'],
                'opt_out_status' => $requestData['OptOutType'],
            ],
        );
    }

    $messageResponse = new MessagingResponse();
    $response->getBody()->write($messageResponse->asXML());
    $response->withHeader('content-type', 'application/xml');
    return $response;
}

The new version of the function receives webhooks and event streams from Twilio. The body of the webhook will contain a parameter named OptOutType if the body of the SMS is one of "STOP", "START", or "HELP".

If an opt-out keyword is received, it will be recorded in the application's SQLite database, along with the sender's Account SID, using PHP-DB. Following this, a small TwiML (Twilio Markup Language) response will be returned to Twilio. This isn't strictly necessary, but is considered a good practice after receiving webhook requests from Twilio.

Then, add the following class constants at the top of the class.

public const string OPT_OUT_TYPE_START = "START";
public const string OPT_OUT_TYPE_STOP  = "STOP";
public const string OPT_OUT_TYPE_HELP  = "HELP";
public const array ALLOWED_OPT_OUT_TYPES = [
    'HELP',
    'START',
    'STOP',
];

These constants avoid using magic strings when checking the value of the OptOutType parameter. Feel free to expand the list of opt out keywords as you see fit.

Then, update the use statements at the top of the class to match the following.

use PhpDb\TableGateway\TableGatewayInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\App as SlimApp;
use Slim\Interfaces\RouteInterface;
use Slim\Middleware\ContentLengthMiddleware;
use Twilio\TwiML\MessagingResponse;
use function array_filter;
use function array_first;
use function assert;
use function in_array;
use function strcasecmp;
use const ARRAY_FILTER_USE_KEY;

Step 4: Migrate to laminas-servicemanager

With the new version of the handleWebhook() defined, you next need to migrate the application from using PHP-DI to laminas-servicemanager. This won't take all that much. Just a bit of refactoring in public/index.php and the creation of a configuration file.

Start off with refactoring public/index.php by replacing lines 27 - 30 with the following:

$aggregator = new ConfigAggregator([
    PhpDbConfigProvider::class,
    PhpDbSQLiteConfigProvider::class,
    new PhpFileProvider(realpath(__DIR__) . '/../config/{{,*.}global,{,*.}local}.php'),
]);
$config = $aggregator->getMergedConfig();
$dependencies = $config['dependencies'];
$dependencies['services']['config'] = $config;
$container = new ServiceManager($dependencies);

The new code instantiates a ServiceManager instance, passing a series of service configurations to its constructor, which PHP-DB requires. In addition, the instance is also provided with the database's configuration, so that PHP-DB knows where to find it and its type.

This change simplifies integrating PHP-DB with the project, as PHP-DB is designed to work with laminas-servicemanager.

Now, add the following code after the change that you made.

try {
    $adapter = $container->get(AdapterInterface::class);
} catch (DependencyException | NotFoundException | InvalidDefinition $e) {
    $logger = $container->get(LoggerInterface::class);
    $logger->debug(
        "Could not retrieve a database adapter from the DI container.",
        [
            'message' => $e->getMessage(),
            'trace' => $e->getTraceAsString(),
            'file' => $e->getFile(),
        ],
    );
    exit();
}
$container->setService(
    TableGatewayInterface::class,
    new TableGateway(
        'user_optouts',
        $container->get(AdapterInterface::class),
    ),
);

This change retrieves an AdapterInterface instance from the application's DI container. This is the core object which PHP-DB uses to connect to databases of the vendors it supports. Assuming that the instance was available, it uses it to register a TableGatewayInterface service with the DI container.

Without getting into too much detail, the object provides an object-oriented representation of a database table; its methods mirror the most common table operations. Check out this post from Martin Fowler, if you'd like to know more about it.

In a production application, when using Event Streams, I strongly recommend that you validate that they came from Twilio.

Now, for the final change to the file. Update the use statements to match the following, ensuring that all of the required namespaces are loaded.

use App\Application;
use DI\Container;
use DI\Definition\Exception\InvalidDefinition;
use DI\DependencyException;
use DI\NotFoundException;
use Dotenv\Dotenv;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\ConfigAggregator\PhpFileProvider;
use Laminas\ServiceManager\ServiceManager;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use PhpDb\Adapter\AdapterInterface;
use PhpDb\ConfigProvider as PhpDbConfigProvider;
use PhpDb\Sqlite\ConfigProvider as PhpDbSQLiteConfigProvider;
use PhpDb\TableGateway\TableGateway;
use PhpDb\TableGateway\TableGatewayInterface;
use Psr\Log\LoggerInterface;
use Slim\Factory\AppFactory;

Now, for the final change: creating the database configuration file. This will tell PHP-DB how to instantiate the AdapterInterface service.

In the config directory, create a new file named db.global.php, and in that file paste the code below.

<?php

declare(strict_types=1);

use PhpDb\Adapter\AdapterInterface;
use PhpDb\Sqlite\Pdo\Driver as SQLiteDriver;

return [
    AdapterInterface::class => [
        'driver'     => SQLiteDriver::class,
        'connection' => [
            'dsn'            => __DIR__ . '/../data/database/database.sqlite3',
            'charset'        => 'utf8',
            'driver_options' => [],
        ],
    ],
];

The configuration tells PHP-DB to use the SQLite driver and that it will find the application's SQLite database in data/database/database.sqlite3.

Step 5: Initialise the database

Thanks to the simplicity of SQLite, and that it's a flat-file database, initialising the application's database is pretty trivial. First up, create a directory for the database by running the following command.

mkdir -p data/database

Then, in the data/database directory, create a file named dump.sql. In that file, paste the SQL below.

CREATE TABLE IF NOT EXISTS user_optouts (
    account_sid TEXT,
    opt_out_status TEXT NOT NULL,
    created DATETIME DEFAULT current_timestamp NOT NULL
);
CREATE INDEX idx_optout_status ON user_optouts(
    account_sid,
    opt_out_status,
    created
);

The SQL above creates a small table named user_optouts with columns for the:

  • Twilio Account SID (account_sid) of the sender of an incoming SMS
  • Sender's opt out preference (opt_out_status)
  • Current date (created)

It also creates a covering index on the table's three columns to speed up searches.

The example is deliberately simplistic, as it's just for the purposes of an example. Because of that, it doesn't enable any performance optimisations, such as WAL (Write-Ahead Logging) mode. Keep this in mind when using SQLite in production.

Now, provision the database using the SQL in dump.sql by running the following command in the data/database directory.

sqlite3 database.sqlite3 < dump.sql

Step 6: Make the application publicly accessible

The application needs to be available on the public internet, so that Twilio can send requests to it. To do that, first start it by running the following command.

composer serve

In the terminal output, you'll see that it's started and listening on port 8080.

Now, create a secure tunnel between port 8080 on your local development machine and the public internet with ngrok, by running the following command.

ngrok http 8080

With ngrok running, copy the Forwarding URL from its terminal output. Keep it handy, as you'll need it in the next step!

Terminal screenshot showing ngrok status with URL highlighted for local container endpoints.

Step 7: Configure your Twilio account

As opt-outs can be tracked using both Incoming Message Webhooks and Event Streams, follow the applicable instructions below to configure your account for both approaches.

Step 7a: Configure your Twilio account for Incoming Message Webhooks

To configure Twilio to send webhook requests to the app when customers send opt-out requests, first, log into the Twilio Console.

Screenshot of API Workbench Dashboard showing API credentials, quick actions, debugger, and alarms tabs.

Then, open the (black) Workbench at the bottom of the page. In the right-hand side, under Quick actions, click Phone numbers.

Messaging configuration settings for United States region with option to edit details highlighted.

Then, click the phone number that will receive messages from customers. In the phone number's details, click the Edit configuration details dropdown, then click the available region that appears; in the screenshot above, you can see that is ”United States (US1)".

Twilio dashboard showing the Edit Messaging Configuration screen with fields for messaging service setup and webhook URL.

In the Edit messaging configuration dialog that appears:

  • Leave Select a messaging service set to "Default"
  • Set Select a method to "Webhook, TwiML Bin, Function, Studio Flow, Proxy Service"
  • Under How do you want to set up your primary method?:
  • Set Select your primary method to "Use Webhooks"
  • Set ​​ What is your webhook URL? to the ngrok Forwarding URL that you copied earlier
  • Set Select a method to handle responses to "HTTP POST"
  • Click Save

Now, you have to enable Advanced Opt-Out for your messaging service. To do that, In the Twilio Console, go to Products & Services > Messaging > Messaging services. There, Select the "Default" Messaging Service. Then, Click the Opt-Out tab. On the Opt-out management page, click the Enable advanced opt-out button.

Email service opt-out management section with options for senders, compliance, and settings.

Step 7b: Configure your Twilio account for Event Streams

To configure Twilio to send event streams to the app when customers send opt-out requests, log into the Twilio Console if you haven't already. Then, navigate through Develop > Event Streams > Sinks in the left-hand side navigation menu. There, click Create Event Stream.

Screenshot of Twilio console for setting up a new webhook sink with inbound SMS as the source.

In the Set up sink form:

  • Enter a value for the Sink description
  • Set Sink type to "Webhook"
  • Set the ngrok Forwarding URL that you copied, earlier, as the value of Webhook destination URL
  • Set Webhook destination method to "POST"
  • Click Next

Then, click Next to skip past the Test sink step.

Screenshot of Twilio console showing event stream setup page with messaging events options selected.

In the Choose event types stage, scroll down to Messaging, and in that section select "com.twilio.messaging.inbound-message.received", and click Next.

Screenshot of Twilio console showing the 'Review schema versions' step, with messaging schema set to v7 (latest).

In Review schema versions, leave the dropdown set to "v7 (latest)" and click Next.

Twilio event stream review screen with options for setup, sink details, and button to create event stream.

Finally, in the Review Event Stream step, click Create Event Stream.

Test that the application works as expected

To test the application, send an SMS to your Twilio phone number, with the message's body being one of "START", "STOP", or "HELP".

The case doesn't matter — but it can't be part of a larger sentence.

Shortly after, you'll receive a reply SMS from Twilio with a message based on what you sent. For example, if you SMS'd "Stop", Twilio would reply with:

You have successfully been unsubscribed. You will not receive any more messages from this number. Reply START to resubscribe.

Now, if you switch back to the terminal tab where either ngrok or Composer are running, you should see that an incoming request from Twilio was received.

Run the following SQL in your database tool to check that your phone number has been marked as being opted out in the application's database, after replacing <YOUR TWILIO ACCOUNT SID> with your Twilio Account SID.

SELECT opt_out_status, created
FROM user_optouts
WHERE account_sid = "<YOUR TWILIO ACCOUNT SID>";

You should see a single record returned, with "STOP" as the value of opt_out_status, and today's date as the value of created. For example:

AC98111111111111111111111111111111|START|2026-07-01 21:54:33
AC98111111111111111111111111111111|HELP|2026-07-01 21:54:38
AC98111111111111111111111111111111|STOP|2026-07-01 21:54:43

That's how to track opt-outs in PHP

By doing so, you'll avoid running afoul of local and internal privacy legislation and ensure that your customers have an excellent experience with your business or organisation.

If you'd like to know more, check out the documentation for Incoming Message Webhooks, Event Streams and opt out keywords. And, if you'd like to learn how to validate event streams webhooks in PHP (ensuring that they came from Twilio), check out this post that I wrote showing how.

Otherwise, I can't wait to see what you build!

Matthew Setter is a PHP and Go Editor in the Twilio Voices team. He’s also the author of Mezzio Essentials and Deploy with Docker Compose. You can find him at msetter@twilio.com. He's also on LinkedIn and GitHub.