Looking to hire Laravel developers? Try LaraJobs

laravel-twilio maintained by beziworld

Description
Lightweight Laravel integration for Twilio: SMS, WhatsApp, Verify, Lookup, signed webhooks, events, notification channel and multiple accounts.
Last update
2026/11/04 17:13 (dev-master)
License
Downloads
1

Comments
comments powered by Disqus

Laravel Twilio

tests Latest Version License

Lightweight Laravel integration for Twilio: SMS, MMS and WhatsApp (including content templates and scheduling), Verify, Lookup, basic voice calls, signed webhooks dispatched as Laravel events, a notification channel and multiple accounts.

Polska wersja dokumentacji

Why this package

The official twilio/sdk covers every Twilio product, and laravel-notification-channels/twilio adds a notification channel on top of it. This package takes a different approach:

  • No twilio/sdk dependency. Requests go through the Laravel HTTP client, so Http::fake() works in your tests and there is no large generated SDK to install.
  • One package for messaging, WhatsApp templates, Verify, Lookup, voice and account balance.
  • Webhooks included: routes for status callbacks and incoming messages, X-Twilio-Signature validation (form and JSON bodies, proxies, configurable public URL), MessageStatusUpdated and MessageReceived events and TwiML replies.
  • Multiple accounts and subaccounts as named connections, plus runtime credentials for multi-tenant apps; webhook signatures are checked with the Auth Token of the account that sent them.
  • Typed, immutable responses, one exception hierarchy, PHP 8.3+, Laravel 12 and 13, PHPStan level 8.

Installation

composer require beziworld/laravel-twilio
php artisan vendor:publish --tag=twilio-config   # optional
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your-auth-token
# Optional: authenticate REST calls with an API key instead of the Auth Token
TWILIO_API_KEY_SID=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_KEY_SECRET=your-api-key-secret

TWILIO_FROM=+15005550006                          # default sender (number or alphanumeric ID)
TWILIO_WHATSAPP_FROM=+14155238886                 # default WhatsApp sender
TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxx   # optional, required for scheduling
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxx      # for Verify
TWILIO_STATUS_CALLBACK=https://your-app.com/twilio/webhooks/status   # optional default StatusCallback

Keep TWILIO_AUTH_TOKEN set even when you use an API key: Twilio signs webhooks with the Auth Token.

SMS and MMS

use Beziworld\Twilio\Facades\Twilio;
use Beziworld\Twilio\Messages\TwilioMessage;

// Shortcut
$message = Twilio::sendSms('+48500100200', 'Your code is 1234');

// Builder
$message = Twilio::send(
    TwilioMessage::create('Your order #1001 has shipped')
        ->to('+48500100200')
        ->from('Shop')                                // alphanumeric sender where supported
        ->media('https://example.com/label.pdf')      // MMS
        ->statusCallback(route('twilio.webhooks.status'))
        ->validityPeriod(3600)
);

$message->sid;          // SM...
$message->status;       // MessageStatus::Queued
$message->numSegments;

with('ShortenUrls', true) passes any other Message parameter. Fetch, list and manage messages:

Twilio::message('SM...');                                   // fetch
Twilio::messages(['To' => '+48500100200'], pageSize: 50);   // first page (MessagePage)
Twilio::allMessages(['DateSent>' => now()->subWeek()])      // LazyCollection over every page
    ->each(fn ($message) => ...);
Twilio::redact('SM...');                                    // remove the body, keep metadata
Twilio::deleteMessage('SM...');

Scheduling

Scheduled messages require a Messaging Service and must be 15 minutes to 35 days ahead:

$message = Twilio::send(
    TwilioMessage::create('Reminder: your visit is tomorrow at 10:00')
        ->to('+48500100200')
        ->messagingService('MG...')        // or TWILIO_MESSAGING_SERVICE_SID
        ->sendAt($visit->starts_at->subDay())
);

Twilio::cancelScheduled($message->sid);

WhatsApp

Recipients and senders receive the whatsapp: prefix automatically.

// Free-form message (inside the 24 hour customer service window)
Twilio::sendWhatsApp('+48500100200', 'Your table is ready');

// Approved content template (required outside the 24 hour window)
Twilio::sendWhatsAppTemplate('+48500100200', 'HXb5b62575e6e4ff6129ad7c8efe1f983e', [
    '1' => '12/1',
    '2' => '3pm',
]);

// Builder with media
Twilio::send(TwilioMessage::whatsapp('Your invoice')->to('+48500100200')->media('https://example.com/invoice.pdf'));

Verify

use Beziworld\Twilio\Enums\VerificationChannel;

Twilio::startVerification('+48500100200');                                   // SMS
Twilio::startVerification('+48500100200', VerificationChannel::WhatsApp, ['Locale' => 'pl']);
Twilio::startVerification('user@example.com', 'email');

if (Twilio::verifyCode('+48500100200', $request->input('code'))) {
    // approved
}

$check = Twilio::checkVerification('123456', to: '+48500100200');   // full Verification object
$check->status;                                                        // VerificationStatus::Approved / Pending

verifyCode() returns false for a wrong code and for verifications that expired, were already approved or ran out of attempts (Twilio answers those with HTTP 404). Pass serviceSid: to use another Verify service.

Lookup

$info = Twilio::lookup('+48500100200', ['line_type_intelligence']);

$info->valid;                                   // bool
$info->phoneNumber;                             // +48500100200
$info->isMobile();                              // LineType::Mobile
$info->lineTypeIntelligence?->carrierName;
$info->package('sms_pumping_risk');             // raw data of any other requested package

Twilio::lookup('500100200', countryCode: 'PL');   // national format

Voice and balance

$call = Twilio::call('+48500100200', twiml: '<Response><Say>Your order has shipped.</Say></Response>');
$call = Twilio::call('+48500100200', url: 'https://example.com/twiml/greeting', options: ['Timeout' => 20]);
Twilio::hangup($call->sid);

$balance = Twilio::balance();   // Balance: ->balance ("12.34"), ->currency ("USD"), ->amount()

Any other endpoint is available through the generic request helper, authenticated with the connection credentials:

Twilio::request('GET', '/Accounts/{AccountSid}/IncomingPhoneNumbers.json', ['PageSize' => 20]);
Twilio::request('POST', 'https://messaging.twilio.com/v1/Services', ['FriendlyName' => 'Shop']);

Webhooks

  1. Enable the routes:

    TWILIO_WEBHOOKS_ENABLED=true
    # Behind a proxy that hides the public host, either configure trusted proxies or set:
    TWILIO_WEBHOOK_URL=https://your-app.com
    
    Route Name Purpose
    POST /twilio/webhooks/status twilio.webhooks.status Message status callbacks
    GET|POST /twilio/webhooks/incoming twilio.webhooks.incoming Incoming SMS and WhatsApp messages
  2. Point Twilio at them: the status URL as StatusCallback (per message or TWILIO_STATUS_CALLBACK) or on the Messaging Service, the incoming URL as "A message comes in" on the number, the WhatsApp sender or the Messaging Service.

  3. Listen to the events:

    use Beziworld\Twilio\Events\MessageReceived;
    use Beziworld\Twilio\Events\MessageStatusUpdated;
    
    Event::listen(function (MessageStatusUpdated $event) {
        SmsLog::where('sid', $event->callback->messageSid)->update([
            'status' => $event->callback->statusValue,
            'error_code' => $event->callback->errorCode,
        ]);
    });
    
    Event::listen(function (MessageReceived $event) {
        $message = $event->message;   // IncomingMessage: from, to, body, media, profileName, waId, ...
    
        if (strtoupper(trim($message->body)) === 'STATUS') {
            $event->reply('Your order is on its way.');   // TwiML reply
        }
    });
    

Without a reply Twilio receives an empty <Response/> and sends nothing. For more control build the TwiML yourself: $event->response()->message('Hi', ['https://example.com/image.jpg'])->redirect('https://example.com/next'). MessagingResponse can also be returned from your own controllers (it is Responsable).

Signature validation

Every webhook request must carry a valid X-Twilio-Signature: an HMAC-SHA1 of the full URL followed by the sorted POST parameters, keyed with the Auth Token. The middleware:

  • picks the Auth Token by the AccountSid parameter, so each configured connection (and each tenant registered with resolveAuthTokenUsing()) is validated with its own token and the event carries the connection name;
  • validates form-encoded POSTs, GET requests (full URL with query) and JSON bodies signed with bodySHA256;
  • accepts the URL with and without the default port, as the official SDKs do;
  • uses the public origin from TWILIO_WEBHOOK_URL when set, otherwise the request as seen by Laravel (configure trusted proxies behind a load balancer). The raw path and query string are kept, because any re-encoding breaks the signature.

Protect your own Twilio routes, for example voice webhooks, with the same middleware:

Route::post('twilio/voice', VoiceController::class)->middleware('twilio.signature');

The validator can be used directly as well: (new SignatureValidator($authToken))->validate($signature, $url, $request->post()). TWILIO_VALIDATE_SIGNATURE=false disables validation; use it only locally.

Status callbacks may arrive out of order and more than once, so keep listeners idempotent.

Notification channel

use Beziworld\Twilio\Messages\TwilioMessage;
use Illuminate\Notifications\Notification;

class OrderShipped extends Notification
{
    public function via(object $notifiable): array
    {
        return ['twilio'];
    }

    public function toTwilio(object $notifiable): TwilioMessage|string
    {
        return TwilioMessage::whatsapp()
            ->template('HX...', ['1' => $this->order->number]);
    }
}

The recipient is taken from the notifiable:

public function routeNotificationForTwilio(): ?string
{
    return $this->phone;   // E.164, e.g. +48500100200
}

On-demand: Notification::route('twilio', '+48500100200')->notify(new OrderShipped($order));

A plain string becomes an SMS. Messages may set their own recipient with ->to() and another account with ->connection('marketing'). A notifiable without a phone number is skipped silently.

Multiple accounts

Define connections in config/twilio.php, like database connections:

'connections' => [
    'default' => [
        'account_sid' => env('TWILIO_ACCOUNT_SID'),
        'auth_token' => env('TWILIO_AUTH_TOKEN'),
        'from' => env('TWILIO_FROM'),
    ],
    'marketing' => [   // another account or a subaccount
        'account_sid' => env('TWILIO_MARKETING_ACCOUNT_SID'),
        'auth_token' => env('TWILIO_MARKETING_AUTH_TOKEN'),
        'messaging_service_sid' => env('TWILIO_MARKETING_MESSAGING_SERVICE_SID'),
    ],
],
Twilio::connection('marketing')->sendSms('+48500100200', 'Spring sale!');

// Credentials stored per tenant
$client = Twilio::using([
    'account_sid' => $tenant->twilio_sid,
    'auth_token' => decrypt($tenant->twilio_token),
    'from' => $tenant->twilio_from,
]);

// Webhooks of tenant accounts
Twilio::resolveAuthTokenUsing(
    fn (string $accountSid): ?string => Tenant::firstWhere('twilio_sid', $accountSid)?->twilio_token
);

Options outside connections (status_callback, timeout, retries, base_urls) are shared and can be overridden per connection.

Error handling

All exceptions extend Beziworld\Twilio\Exceptions\TwilioException:

Exception When
ApiException Twilio rejected the request (errorCode(), httpStatus(), moreInfo(), payload(), isRateLimited(), isNotFound(), isAuthenticationError()) or could not be reached
InvalidMessage Message without recipient, content or sender, template combined with body, scheduling without a Messaging Service
InvalidConfiguration Missing Account SID or credentials, unknown connection, missing Verify service
CouldNotSendNotification Notification without a valid toTwilio()
use Beziworld\Twilio\Exceptions\ApiException;

try {
    Twilio::sendSms($phone, $text);
} catch (ApiException $e) {
    if ($e->errorCode() === 21211) {
        // invalid "To" number
    }
}

Retries

Requests rejected with HTTP 429 (error 20429) were not processed by Twilio and are retried TWILIO_RETRIES times (default 2) with exponential backoff. Connection errors are retried only for reads: a message request that timed out may still have been accepted, and repeating it could send the message twice.

Testing your application

Http::fake([
    'api.twilio.com/*' => Http::response(['sid' => 'SM123', 'status' => 'queued'], 201),
]);

// ... code that sends a message

Http::assertSent(fn ($request) => ($request->data()['To'] ?? null) === '+48500100200');

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need a custom integration or a Laravel project? Get in touch through beziworld.eu.

Other packages by the author

License

MIT. See LICENSE.md. This package is not affiliated with, endorsed by or sponsored by Twilio Inc. "Twilio" and "WhatsApp" are trademarks of their respective owners.