beziworld / laravel-twilio
Lightweight Laravel integration for Twilio: SMS, WhatsApp, Verify, Lookup, signed webhooks, events, notification channel and multiple accounts.
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 16:36:48 UTC
README
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.com
Route 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.