DEV Community

Carlos Santos
Carlos Santos

Posted on • Edited on

5 1

✉ Laravel Twilio SMS Implementation

Laravel Twilio SMS Implementation

Introduction

Twilio integrations have been a lot more common than I thought during the last few years. So I wanted to write the article I wish I have found the first time a had to do it.

Twilio's SMS API is a flexible building block that can take you from sending your first text message all the way to sending and receiving millions.
In this article, I will talk about how to set up a Laravel application Twilio configuration and start sending SMS.

After that, we will dig deeper into how to keep track of the status of sent messages and keep a local updated representation of Twilio messages.

Finally, we will prepare the application to receive notifications every time your defined Twilio phone number gets a message and then store the received message locally.

 

Configuration and sending the first SMS

 
1. Get Twilio Credentials and Phone Number
Twilio Credentials
Keep in mind that a trial phone number can only send SMS to verified phones. This means you will have to add the phone number you intend to send messages to a verified numbers list.

Twilio Verified Numbers

 
2. Add Twilio Credentials to Laravel configuration.

.env file

TWILIO_SID=YOUR_ACCOUNT_SID
TWILIO_AUTH_TOKEN=YOUR_ACCOUNT_AUTH_TOKEN
TWILIO_FROM_NUMBER=YOUR_ACCOUNT_FROM_NUMBER(e.g. +1XXXXXXXXXX)
Enter fullscreen mode Exit fullscreen mode

/config/app.php

'twilio' => [
    'sid' => env('TWILIO_SID'),
    'auth_token' => env('TWILIO_AUTH_TOKEN'),
    'from_number' => env('TWILIO_FROM_NUMBER')
]
Enter fullscreen mode Exit fullscreen mode

 
3. Install Twilio SDK for PHP
composer require twilio/sdk

 
4. Create Twilio Service Class
It is convenient to handle all Twilio SMS operations from a single class that is globally available. So every time you send a message you will do it through this service.

<?php
namespace App\Services\Communications;
use Exception;
use Twilio\Rest\Client;
class TwilioService
{
/**
* Twilio Client
*/
protected $client;
/**
* Twilio instance parameters
*/
protected $sid;
protected $token;
protected $from_number;
/**
* Status Callback Url
*/
protected $status_callback_url;
/**
*
* @throws \Twilio\Exceptions\ConfigurationException
*/
public function __construct()
{
$this->sid = config('app.twilio.sid');
$this->token = config('app.twilio.auth_token');
$this->from_number = config('app.twilio.from_number');
$this->client = new Client($this->sid,$this->token);
}
public function sendMessage($to, $body) : array
{
$result = ['success' => false, 'data' => [], 'message' => ''];
try{
$options = array();
$options['body'] = $body;
$options['from'] = $this->from_number;
$apiResponse = $this->client->messages->create($to, $options);
$result['data'] = $apiResponse->toArray();
if(!empty($result['data']['errorCode'])) {
throw new Exception('Send SMS request failed');
}
$result['success'] = true;
$result['message'] = 'SMS request success';
}catch(Exception $ex){
$result['success'] = false;
$result['message'] = $ex->getMessage();
}
return $result;
}
/**
* Get Twilio Client
* @return Client
*/
public function getClient()
{
return $this->client;
}
}

Remember to bind the service so it is globally accessible. In this case, we will create a provider CustomServiceProvider and bind TwilioService in the boot function.

php artisan make:provider CustomServiceProvider

<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class CustomServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
$this->app->bind('TwilioService', \App\Services\Communications\TwilioService::class);
}
}

And then add App\Providers\CustomServiceProvider::class into the $providers array on config/app.php.

Now the TwilioService should be available and we can test it from anywhere.

Also not required, but it will be convenient to add a new channel at /config/logging.php to log Twilio operations, at least for debugging.

...
'twilio' => [
    'driver' => 'single',
    'path' => storage_path('logs/twilio.log'),
    'level' => 'debug',
],
...
Enter fullscreen mode Exit fullscreen mode

 
5. Create Twilio SMS Controller.
If you only care about sending messages then you don't really need to create a controller. You can test and use the TwilioService sendMessage function from anywhere.

For this implementation, we will use this controller to provide a sendTest function. Later it will also be used to handle Twilio SMS Status Callback requests and Twilio SMS Received requests.

php artisan make:controller TwilioSmsController

<?php
namespace App\Http\Controllers;
use Exception;
use Illuminate\Http\Request;
class TwilioSmsController extends Controller
{
/**
* Test sms send
* @return mixed
*/
public function sendTest(Request $request) {
try {
// Make sure it is E.164 formatting
$toPhoneNumber = 'TO_PHONE_NUMBER_YOU_ARE_TESTING';
$sendResult = app('TwilioService')->sendMessage($toPhoneNumber, 'Hi, this is a test');
if(!isset($sendResult['success']) || !$sendResult['success']) {
throw new Exception(($sendResult['message'] ?? ''));
}
return $sendResult;
}catch (Exception $ex) {
return 'Send SMS Failed - '.$ex->getMessage();
}
}
}

Add testing endpoint to your API routes.

Route::any(
'/twilio/send-test',
[TwilioSmsController::class, 'sendTest'])
->name('twilio.send-test');
Enter fullscreen mode Exit fullscreen mode

You are ready to make your first test. Hopefully, everything goes right on the first try. If not, you will get a debug error message.

Common errors you can get:

That's it! 🎉 If you only need to send messages then you are ready to go.

If you want to keep track of the messages you sent and also be able to receive messages in your application then keep reading 👀.

 

Track Twilio SMS status changes

When we make a send SMS request to Twilio the message has to go through a sequence of statuses.

  • accepted
  • queued
  • sending
  • sent
  • delivery_unknown
  • delivered
  • undelivered
  • failed

The response we get from making a successful send SMS request will tell us that the message status is queued. This just means that Twilio accepted the request and it was added to the queue of messages to send, but we don't know if it was actually sent.

If we want to keep track of an SMS status we have to provide a statusCallback parameter on each request. The statusCallback will be the URL to a webhook in our application that will be prepared to receive requests from Twilio.

 
1. Create TwilioSms Model and TwilioSmsLog Model.
TwilioSms will represent a Twilio SMS in our application and TwilioSmsLog will represent each event related to an SMS (status change, error, etc).

php artisan make:model TwilioSms -m
php artisan make:model TwilioSmsLog -m
Enter fullscreen mode Exit fullscreen mode

Models

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TwilioSms extends Model
{
use HasFactory;
protected $table = 'twilio_sms';
protected $guarded = [];
}
view raw TwilioSms.php hosted with ❤ by GitHub
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TwilioSmsLog extends Model
{
use HasFactory;
protected $table = 'twilio_sms_log';
protected $guarded = [];
}

Migrations

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTwilioSmsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('twilio_sms', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('sid', 500);
$table->enum('direction', ['sent','received']);
$table->string('from', 50);
$table->string('to', 50);
$table->string('body', 1600)->nullable()->default(null);
$table->enum('status', ['request_error','accepted','queued','sending','sent','receiving','received','delivered','undelivered','failed','read'])->default('request_error');
// Indexes
$table->unique(['sid']);
$table->index(['from']);
$table->index(['to']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('twilio_sms');
}
}

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTwilioSmsLogsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('twilio_sms_log', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('twilio_sms_id')->unsigned()->nullable()->default(null);
$table->string('sms_sid', 500)->nullable()->default(null);
$table->string('sms_message_sid', 500)->nullable()->default(null);
$table->enum('event', ['send_sms_request',
'send_sms_request_error',
'message_received',
'segment_status_changed',
'status_changed',
'invalid_request_sid_not_defined',
'twilio_sms_not_found',
'generic_error',
'not_categorized'])->default('not_categorized');
$table->string('new_status', 191)->nullable()->default(null);
$table->json('details')->nullable()->default(null);
$table->timestamps();
// Indexes
$table->index(['twilio_sms_id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('twilio_sms_log');
}
}

 
2. Create status callback webhook route and implement Twilio Request Validation middleware.
Add new webhook url to api routes.

/**
 * Twilio statusCallBack webhook
 * is-twilio-request middleware makes sure only twilio has access to this route
 */
Route::any('/twilio/webhook/status-changed', [TwilioController::class, 'statusChanged'])->middleware(['is-twilio-request'])->name('api.twilio.status-changed');
Enter fullscreen mode Exit fullscreen mode

Notice that we are using the middleware is-twilio-request. This route will be open to the public so we need to make sure that it only serves valid requests from Twilio. We can accomplish that by using the RequestValidator method provided by the Twilio PHP SDK.

Create Middleware
php artisan make:middleware TwilioRequestIsValid

<?php
namespace App\Http\Middleware;
use Closure;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Twilio\Security\RequestValidator;
class TwilioRequestIsValid
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
try {
$twilioToken = config('app.twilio.auth_token');
if(empty($twilioToken)) {
throw new Exception('Token not found');
}
$requestValidator = new RequestValidator($twilioToken);
$requestData = $request->toArray();
// Switch to the body content if this is a JSON request.
if (array_key_exists('bodySHA256', $requestData)) {
$requestData = $request->getContent();
}
$isValid = $requestValidator->validate(
$request->header('X-Twilio-Signature'),
$request->fullUrl(),
$requestData
);
if (!$isValid) {
throw new Exception();
}
}catch (\Throwable $ex) {
return new Response(['success' => false, 'message' => 'Failed Authentication'], 403);
}
return $next($request);
}
}

Add the new middleware to app/Http/Kernel.php $routeMiddleware array.

/**
 * The application's route middleware.
 *
 * These middleware may be assigned to groups or used individually.
 *
 * @var array
 */
protected $routeMiddleware = [
    ...
    'is-twilio-request' => \App\Http\Middleware\TwilioRequestIsValid::class,
];
Enter fullscreen mode Exit fullscreen mode

 
3. Update Twilio Service. Add status callback, create TwilioSmsModel on send and log.

TwilioService final version:

<?php
namespace App\Services\Communications;
use Exception;
use Illuminate\Support\Facades\Log;
use Twilio\Rest\Client;
use App\Models\TwilioSms;
use App\Models\TwilioSmsLog;
class TwilioService
{
/**
* Twilio Client
*/
protected $client;
/**
* Twilio instance parameters
*/
protected $sid;
protected $token;
protected $from_number;
/**
* Status Callback Url
*/
protected $status_callback_url;
/**
*
* @throws \Twilio\Exceptions\ConfigurationException
*/
public function __construct()
{
$this->sid = config('app.twilio.sid');
$this->token = config('app.twilio.auth_token');
$this->from_number = config('app.twilio.from_number');
$this->status_callback_url = route('api.twilio.status-changed');
$this->client = new Client($this->sid,$this->token);
}
public function sendMessage($to, $body) :array {
$result = ['success' => false, 'data' => [], 'message' => '', 'twilio_sms_id' => null];
try{
$options = array();
$options['body'] = $body;
$options['from'] = $this->from_number;
$options['statusCallback'] = $this->status_callback_url;
$apiResponse = $this->client->messages->create($to, $options);
$result['data'] = $apiResponse->toArray();
if(!empty($result['data']['errorCode'])) {
throw new Exception('Send sms request failed');
}
$result['success'] = true;
$createdSms = TwilioSms::create([
'sid' => $result['data']['sid'],
'direction' => 'sent',
'from' => $result['data']['from'],
'to' => $result['data']['to'],
'status' => $result['data']['status'],
'body' => $result['data']['body'],
]);
$result['twilio_sms_id'] = $createdSms->id ?? null;
$this->log([
'twilio_sms_id' => $createdSms->id ?? null,
'sms_sid' => $result['data']['sid'] ?? null,
'event' => 'send_sms_request',
'new_status' => $result['data']['status'] ?? null,
'details' => $result['data'],
]);
}catch(Exception $ex){
$result['success'] = false;
$result['message'] = $ex->getMessage();
$result['data']['error_message'] = $result['message'];
$this->log([
'twilio_sms_id' => null,
'sms_sid' => $result['data']['sid'] ?? null,
'event' => 'send_sms_request_error',
'new_status' => $result['data']['status'] ?? null,
'details' => $result['data'] ?? [],
]);
}
return $result;
}
private function log($data) {
try {
if(empty($data)) {
throw new Exception('Invalid log data');
}
$logData = [
'twilio_sms_id' => $data['twilio_sms_id'] ?? null,
'sms_sid' => $data['sms_sid'] ?? null,
'sms_message_sid' => $data['sms_sid'] ?? null,
'event' => $data['event'] ?? 'generic_error',
'new_status' => $data['new_status'] ?? null,
'details' => json_encode(($data['details'] ?? [])),
];
TwilioSmsLog::create($logData);
}catch (Exception $ex) {
// NOTICE: Should probably create a log channel just for Twilio
Log::channel('single')->error($ex->getFile().' :: '.$ex->getLine().' :: '.$ex->getMessage());
}
}
/**
* Get Twilio Client
* @return Client
*/
public function getClient(){
return $this->client;
}
}

Now every time we make a sendMessage request the following will happen:

  • Add callback URL to sendMessage request.
  • If the request was successful create TwilioSms record on DB.
  • Create TwilioSmsLog.

Created message.
Created message

Created log.
Created log

 
4. Handle Twilio Status Changed Request.
Add statusChanged Method to TwilioSmsController. The logic is just an example of implementation. You can make any adjustment as long as you return response(['success' => true], 200).

In summary, we log every request and if we can match the request with an SMS in our application and the request status is different from the current TwilioSms status we update it.

TwilioSmsController with status changed method:

<?php
namespace App\Http\Controllers;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\TwilioSms;
use App\Models\TwilioSmsLog;
class TwilioSmsController extends Controller
{
/**
* This function is a public exposed route that handles twilio requests (from twilio) to inform status changes from messages
* Format application/x-www-form-urlencoded
* Method POST
* Request parameters
* SmsSid: SM2xxxxxx
* SmsStatus: sent
* Body: McAvoy or Stewart? These timelines can get so confusing.
* MessageStatus: sent
* To: +1512zzzyyyy
* MessageSid: SM2xxxxxx
* AccountSid: ACxxxxxxx
* From: +1512xxxyyyy
* ApiVersion: 2010-04-01
*/
public function statusChanged(Request $request) {
// Create log
try{
$logData = [
'sms_sid' => $request['SmsSid'] ?? null,
'sms_message_sid' => $request['MessageSid'] ?? null,
'twilio_sms_id' => null,
'event' => 'not_categorized',
'new_status' => $request['MessageStatus'] ?? null,
'details' => json_encode(($request->all() ?? [])),
];
try {
if(!isset($request['SmsSid'])) {
$logData['event'] = 'invalid_request_sid_not_defined';
throw new Exception('Sid not defined. Could not match with system sms.');
}
$twilioSms = TwilioSms::select('id', 'sid', 'status')->where('sid', $request['SmsSid'])->first();
if(empty($twilioSms->id)) {
$logData['event'] = 'twilio_sms_not_found';
throw new Exception('Twilio sms sid: '.$request['SmsSid'].' was not found.');
}
$logData['twilio_sms_id'] = $twilioSms->id;
$logData['event'] = 'partial_status_changed';
if(isset($request['SmsStatus']) && $twilioSms->status != $request['SmsStatus']) {
$logData['event'] = 'status_changed';
$twilioSms->status = $request['SmsStatus'];
$twilioSms->save();
}
}catch(Exception $ex2) {
Log::channel('twilio')->error($ex2->getFile().' :: '.$ex2->getMessage());
}
TwilioSmsLog::create($logData);
}catch(Exception $ex) {
Log::channel('twilio')->error($ex->getFile().' :: '.$ex->getMessage().' :: '.json_encode(($request->all() ?? [])));
}
return response(['success' => true], 200);
}
/**
* Test sms send
* @return mixed
*/
public function sendTest(Request $request) {
try {
// Make sure it is E.164 formatting
$toPhoneNumber = 'TO_PHONE_NUMBER_YOU_ARE_TESTING';
$sendResult = app('TwilioService')->sendMessage($toPhoneNumber, 'Hi, this is a test');
if(!isset($sendResult['success']) || !$sendResult['success']) {
throw new Exception(($sendResult['message'] ?? ''));
}
return $sendResult;
}catch (Exception $ex) {
return 'Send SMS Failed - '.$ex->getMessage();
}
}
}

With the webhook in place, we will start to see our messages go through different states. It usually just takes a few seconds to go from queued to sent.

It will look like this in the database.
logs

That’s it for it tracking SMS status changes and keeping an updated representation of each Twilio SMS sent by the application. The next step is to receive and store messages.

 

Receive SMS

 
1. Create messageReceived method on TwilioSmsController.

TwilioSmsController final version:

<?php
namespace App\Http\Controllers;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\TwilioSms;
use App\Models\TwilioSmsLog;
class TwilioSmsController extends Controller
{
/**
* This function is a public exposed route that handles twilio requests (from twilio) to inform status changes from messages
* Format application/x-www-form-urlencoded
* Method POST
* Request parameters
* SmsSid: SM2xxxxxx
* SmsStatus: sent
* Body: McAvoy or Stewart? These timelines can get so confusing.
* MessageStatus: sent
* To: +1512zzzyyyy
* MessageSid: SM2xxxxxx
* AccountSid: ACxxxxxxx
* From: +1512xxxyyyy
* ApiVersion: 2010-04-01
*/
public function statusChanged(Request $request) {
try{
$logData = [
'sms_sid' => $request['SmsSid'] ?? null,
'sms_message_sid' => $request['MessageSid'] ?? null,
'twilio_sms_id' => null,
'event' => 'not_categorized',
'new_status' => $request['MessageStatus'] ?? null,
'details' => json_encode(($request->all() ?? [])),
];
try {
if(!isset($request['SmsSid'])) {
$logData['event'] = 'invalid_request_sid_not_defined';
throw new Exception('Sid not defined. Could not match with system sms.');
}
$twilioSms = TwilioSms::select('id', 'sid', 'status')->where('sid', $request['SmsSid'])->first();
if(empty($twilioSms->id)) {
$logData['event'] = 'twilio_sms_not_found';
throw new Exception('Twilio sms sid: '.$request['SmsSid'].' was not found.');
}
$logData['twilio_sms_id'] = $twilioSms->id;
$logData['event'] = 'partial_status_changed';
if(isset($request['SmsStatus']) && $twilioSms->status != $request['SmsStatus']) {
$logData['event'] = 'status_changed';
$twilioSms->status = $request['SmsStatus'];
$twilioSms->save();
}
}catch(Exception $ex2) {
Log::channel('twilio')->error($ex2->getFile().' :: '.$ex2->getMessage());
}
TwilioSmsLog::create($logData);
}catch(Exception $ex) {
Log::channel('twilio')->error($ex->getFile().' :: '.$ex->getMessage().' :: '.json_encode(($request->all() ?? [])));
}
return response(['success' => true], 200);
}
/**
* Handles message_received requests from Twilio
*
* Format application/x-www-form-urlencoded
* Method POST
* Request parameters
* MessageSid A 34 character unique identifier for the message. May be used to later retrieve this message from the REST API.
* SmsSid Same value as MessageSid. Deprecated and included for backward compatibility.
* AccountSid The 34 character id of the Account this message is associated with.
* MessagingServiceSid The 34 character id of the Messaging Service associated with the message.
* From The phone number or Channel address that sent this message.
* To The phone number or Channel address of the recipient.
* Body The text body of the message. Up to 1600 characters long.
* NumMedia The number of media items associated with your message
* Observations:
* All phone numbers in requests from Twilio are in E.164 format if possible. For example, (415) 555-4345 would come through as '+14155554345'. However, there are occasionally cases where Twilio cannot normalize an incoming caller ID to E.164. In these situations, Twilio will report the raw caller ID string.
*/
public function messageReceived(Request $request) {
try{
$logData = [
'sms_sid' => $request['SmsSid'] ?? null,
'sms_message_sid' => $request['MessageSid'] ?? null,
'twilio_sms_id' => null,
'event' => 'not_categorized',
'details' => json_encode(($request->all() ?? [])),
];
if(!empty($request['SmsSid'])) {
$logData['event'] = 'message_received';
$logData['new_status'] = 'received';
$created = TwilioSms::create([
'sid' => $request['SmsSid'] ?? '',
'direction' => 'received',
'from' => $request['From'] ?? '',
'to' => $request['To'] ?? '',
'status' => $request['SmsStatus'] ?? 'error',
'body' => $request['Body'] ?? ''
]);
if(!empty($created->id)) {
$logData['twilio_sms_id'] = $created->id;
}
}
TwilioSmsLog::create($logData);
}catch(Exception $ex) {
Log::channel('twilio')->error($ex->getFile().' :: '.$ex->getMessage().' :: '.json_encode(($request->all() ?? [])));
}
// Proper TwiML Empty response (Do not auto reply SMS)
return response('<Response></Response>', 200)->header('Content-Type', 'text/html');
}
/**
* Test sms send
* @return mixed
*/
public function sendTest(Request $request) {
try {
// Make sure it is E.164 formatting
$toPhoneNumber = 'TO_PHONE_NUMBER';
$sendResult = app('TwilioService')->sendMessage($toPhoneNumber, 'Hi, this is a test');
if(!isset($sendResult['success']) || !$sendResult['success']) {
throw new Exception(($sendResult['message'] ?? ''));
}
return $sendResult;
}catch (Exception $ex) {
return 'Send SMS Failed - '.$ex->getMessage();
}
}
}

 
2. Add webhook route.

...
...
/**
* Twilio message received webhook
* is-twilio-request middleware makes sure only twilio has access to this route
*/
Route::any('/twilio/webhook/message-received', [TwilioSmsController::class, 'messageReceived'])
->middleware(['is-twilio-request'])
->name('api.twilio.message-received');
...
...
Enter fullscreen mode Exit fullscreen mode

 
3. Add webhook URL to a phone number in Twilio console.

Find your phone number details page.
console

Click on the phone number you want to manage and then find the Messaging section on the details page. Here you can define a “message comes in” action. Select webhook and input your webhook URL.
message webhook

Save and you are all set to start receiving SMS in your application.

Whenever a message comes in we are going to create a TwilioSms record and a TwilioSmsLog record.
sms
log

 

Conclusion

We accomplished:
✅ Send SMS.
✅ Track Sent SMS Status.
✅ Keep local updated representation of a Twilio SMS.
✅ Every time a Twilio phone receives a message we get it and store it.

Possible gotchas:

  • Test with phone numbers in E.164 formatting.
  • Trial phone numbers can only send messages to verified numbers.
  • Sometimes sending a message to a region is disabled, so you have to enable the region on the Twilio console.
  • Webhook URL must be publicly accessible. No auth. Not behind a server configuration restriction.

I hope this article was helpful.

If you have any feedback or found mistakes, please don’t hesitate to reach out to me.

Top comments (0)