laravel-twilio maintained by beziworld
Laravel Twilio
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.
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/sdkdependency. Requests go through the Laravel HTTP client, soHttp::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),
MessageStatusUpdatedandMessageReceivedevents 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);
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
-
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.comRoute Name Purpose POST /twilio/webhooks/statustwilio.webhooks.statusMessage status callbacks GET|POST /twilio/webhooks/incomingtwilio.webhooks.incomingIncoming SMS and WhatsApp messages -
Point Twilio at them: the status URL as
StatusCallback(per message orTWILIO_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. -
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
AccountSidparameter, so each configured connection (and each tenant registered withresolveAuthTokenUsing()) 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_URLwhen 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.
- E-mail: biuro@beziworld.eu
- Issues and feature requests: GitHub issues
- Security vulnerabilities: see SECURITY.md
Need a custom integration or a Laravel project? Get in touch through beziworld.eu.
Other packages by the author
- beziworld/laravel-smsapi: SMSAPI SMS gateway
- beziworld/laravel-przelewy24: Przelewy24 payments
- beziworld/laravel-tpay: Tpay payments and BLIK
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.