Laravel + Twilio Integration
Send SMS, handle voice calls, and build chatbots with Twilio in your Laravel application.
What You Can Build
SMS Notifications
Send order confirmations, appointment reminders, and alerts via SMS.
Two-Factor Authentication
Verify user identities with SMS verification codes.
Voice Notifications
Automated voice calls for critical alerts and appointment reminders.
WhatsApp Messages
Reach users on WhatsApp for higher engagement rates.
Version Scope
The implementation below targets Laravel 12, PHP 8.3, and twilio/sdk 8.11.6. It uses Twilio's official helper library directly so the same client supports Messaging, Verify, WhatsApp, and Voice APIs.
Production defaults
Use API keys for outbound REST requests, retain the Auth Token only for webhook validation, send through a Messaging Service, normalize recipients to E.164, and run sends through a monitored queue.
Important boundary
Twilio accepting an API request does not prove carrier delivery. Persist the returned Message SID and treat signed status callbacks as the source of truth for delivered, undelivered, failed, and read states.
Code-behind example
Complete Laravel Twilio setup: queued SMS, callbacks, Verify, and security
This example sends consented SMS messages through a Messaging Service, records an internal outbound message before contacting Twilio, validates every callback signature, and prevents delayed callbacks from regressing delivery state. Adapt the model names and retention rules to the application, but keep the trust boundaries intact.
1. Install SDK 8.11 and configure credentials
API keys limit the blast radius of outbound credentials. The primary Auth Token remains necessary because Twilio uses it to sign callbacks.
composer require twilio/sdk:^8.11
TWILIO_ACCOUNT_SID=AC...
TWILIO_API_KEY=SK...
TWILIO_API_SECRET=...
TWILIO_AUTH_TOKEN=...
TWILIO_MESSAGING_SERVICE_SID=MG...
TWILIO_VERIFY_SERVICE_SID=VA...
2. Add environment-backed configuration
Read environment values only from config files. Keep real credentials out of source control, logs, exception pages, and client-side JavaScript.
// config/services.php
'twilio' => [
'account_sid' => env('TWILIO_ACCOUNT_SID'),
'api_key' => env('TWILIO_API_KEY'),
'api_secret' => env('TWILIO_API_SECRET'),
'auth_token' => env('TWILIO_AUTH_TOKEN'),
'messaging_service_sid' => env('TWILIO_MESSAGING_SERVICE_SID'),
'verify_service_sid' => env('TWILIO_VERIFY_SERVICE_SID'),
],
3. Bind the REST client and webhook validator
Bind both SDK objects once in AppServiceProvider::register(). The REST client authenticates with an API key and secret; the request validator intentionally uses the account Auth Token.
use Twilio\Rest\Client;
use Twilio\Security\RequestValidator;
public function register(): void
{
$this->app->singleton(Client::class, fn (): Client => new Client(
(string) config('services.twilio.api_key'),
(string) config('services.twilio.api_secret'),
(string) config('services.twilio.account_sid'),
));
$this->app->singleton(RequestValidator::class, fn (): RequestValidator => new RequestValidator(
(string) config('services.twilio.auth_token'),
));
}
4. Protect send routes and expose one signed callback
Authenticate and throttle user-initiated sends. The status callback remains unauthenticated because Twilio calls it, but it must be HTTPS, signature-validated, and excluded from CSRF validation. A UUID route key lets an early callback find the internal record before the send job saves Twilio's Message SID. The durable record should have a unique UUID, a nullable unique twilio_sid, status, error code, timestamps, and an encrypted or deliberately short-lived body.
use App\Http\Controllers\Messaging\SendSmsController;
use App\Http\Controllers\Messaging\TwilioStatusController;
use Illuminate\Support\Facades\Route;
Route::post('/messages/sms', SendSmsController::class)
->middleware(['auth', 'throttle:6,1'])
->name('messages.sms.store');
Route::post('/twilio/status/{outboundMessage:uuid}', TwilioStatusController::class)
->name('twilio.status');
In a fresh Laravel 12 application, configure validateCsrfTokens(except: ['twilio/status/*']) in bootstrap/app.php. In an upgraded Laravel 10-style structure, add 'twilio/status/*' to VerifyCsrfToken::$except. Keep APP_URL, trusted proxy handling, and the Twilio Console callback on the same public HTTPS URL or signatures will fail.
5. Authorize the contact, enforce consent, and queue the send
Never accept an arbitrary destination from a public controller. Authorize a contact owned by the current user, check recorded consent and opt-out state server-side, then create the durable outbound record before dispatching.
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class SendSmsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->contacts()
->whereKey($this->integer('contact_id'))
->exists() ?? false;
}
public function rules(): array
{
return [
'contact_id' => ['required', 'integer'],
'message' => ['required', 'string', 'max:320'],
];
}
public function messages(): array
{
return [
'message.max' => 'Keep SMS messages under 320 characters to control segmentation and cost.',
];
}
}
namespace App\Http\Controllers\Messaging;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\SendSmsRequest;
use App\Jobs\SendSms;
use Illuminate\Http\RedirectResponse;
class SendSmsController extends Controller
{
public function __invoke(SendSmsRequest $request): RedirectResponse
{
$contact = $request->user()->contacts()
->findOrFail($request->integer('contact_id'));
abort_if(
is_null($contact->sms_consented_at) || $contact->sms_opted_out_at,
422,
'This contact cannot receive SMS messages.'
);
$outboundMessage = $contact->outboundMessages()->create([
'body' => $request->validated('message'),
'status' => 'queued',
]);
SendSms::dispatch($outboundMessage->getKey());
return back()->with('status', 'Message queued for delivery.');
}
}
6. Send through a Messaging Service and handle API errors
Recheck consent in the worker because preferences can change after dispatch. This job deliberately allows one attempt: Twilio has no general Message-create idempotency key, so an automatic retry after a response timeout can send a duplicate even if the first API request succeeded.
namespace App\Jobs;
use App\Models\OutboundMessage;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Twilio\Exceptions\TwilioException;
use Twilio\Rest\Client;
class SendSms implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public int $outboundMessageId) {}
public function handle(Client $twilio): void
{
$outboundMessage = OutboundMessage::query()
->with('contact')
->findOrFail($this->outboundMessageId);
if (is_null($outboundMessage->contact->sms_consented_at) ||
$outboundMessage->contact->sms_opted_out_at) {
$outboundMessage->update(['status' => 'skipped']);
return;
}
try {
$message = $twilio->messages->create(
$outboundMessage->contact->phone_e164,
[
'messagingServiceSid' => config('services.twilio.messaging_service_sid'),
'body' => $outboundMessage->body,
'statusCallback' => route('twilio.status', [
'outboundMessage' => $outboundMessage->uuid,
]),
],
);
$outboundMessage->update(['twilio_sid' => $message->sid]);
} catch (TwilioException $exception) {
$outboundMessage->update(['status' => 'send_failed']);
Log::warning('Twilio SMS send failed.', [
'outbound_message_id' => $outboundMessage->getKey(),
'twilio_code' => $exception->getCode(),
]);
throw $exception;
}
}
}
Do not log the phone number, message body, Auth Token, or API secret. Alert on failed jobs and reconcile ambiguous sends against Twilio message logs before manually retrying. Encrypt or redact stored message bodies according to the product's retention policy.
7. Validate every callback before reading its fields
Twilio signs the exact public URL plus every form parameter and may add parameters without notice. Pass the complete form body to SDK 8.11's RequestValidator; never validate a hand-picked field list or trust IP allowlists.
namespace App\Http\Requests\Messaging;
use App\Models\OutboundMessage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Twilio\Security\RequestValidator;
class TwilioStatusRequest extends FormRequest
{
public function authorize(RequestValidator $validator): bool
{
$outboundMessage = $this->route('outboundMessage');
if (! $outboundMessage instanceof OutboundMessage) {
return false;
}
return $validator->validate(
(string) $this->header('X-Twilio-Signature'),
route('twilio.status', ['outboundMessage' => $outboundMessage->uuid]),
$this->request->all(),
);
}
public function rules(): array
{
return [
'MessageSid' => ['required', 'string', 'size:34', 'regex:/^(SM|MM)[0-9a-fA-F]{32}$/'],
'MessageStatus' => [
'required',
Rule::in(['accepted', 'queued', 'sending', 'sent', 'delivered', 'undelivered', 'failed', 'read']),
],
'ErrorCode' => ['nullable', 'string', 'max:10'],
];
}
}
8. Record duplicates and prevent out-of-order regressions
Add a unique database constraint such as $table->char('callback_key', 64)->unique() on a non-null callback hash. Do not rely on a composite unique index containing nullable error_code, because several databases allow multiple NULL values. The parent message also needs a boolean status_reconciliation_required column. Lock the parent row, accept the first Message SID, and append every unique callback before updating its summary status. Lower-rank callbacks remain harmless regressions. Because network latency can reorder callbacks and they contain no sequence number, never let arrival order choose between conflicting delivered, undelivered, and failed outcomes. Flag the conflict and fetch the Message resource by SID in an idempotent queued reconciliation job; that canonical API status updates the parent and clears the flag while the callback rows preserve the full audit trail.
namespace App\Http\Controllers\Messaging;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\TwilioStatusRequest;
use App\Jobs\ReconcileTwilioMessageStatus;
use App\Models\OutboundMessage;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TwilioStatusController extends Controller
{
public function __invoke(
TwilioStatusRequest $request,
OutboundMessage $outboundMessage,
): Response {
$data = $request->validated();
$rank = [
'accepted' => 0,
'queued' => 1,
'sending' => 2,
'sent' => 3,
'delivered' => 4,
'undelivered' => 4,
'failed' => 4,
'read' => 5,
];
$terminalStatuses = ['delivered', 'undelivered', 'failed'];
DB::transaction(function () use (
$outboundMessage,
$data,
$rank,
$terminalStatuses,
): void {
$message = OutboundMessage::query()
->lockForUpdate()
->findOrFail($outboundMessage->getKey());
if ($message->twilio_sid && $message->twilio_sid !== $data['MessageSid']) {
Log::warning('Twilio callback Message SID mismatch.', [
'outbound_message_id' => $message->getKey(),
]);
return;
}
if (is_null($message->twilio_sid)) {
$message->update(['twilio_sid' => $data['MessageSid']]);
}
$callback = $message->statusCallbacks()->firstOrCreate([
'callback_key' => hash('sha256', implode('|', [
$data['MessageSid'],
$data['MessageStatus'],
$data['ErrorCode'] ?? '',
])),
], [
'status' => $data['MessageStatus'],
'error_code' => $data['ErrorCode'] ?? null,
'received_at' => now(),
]);
if (! $callback->wasRecentlyCreated) {
return;
}
$hasTerminalConflict = $message->status !== $data['MessageStatus']
&& in_array($message->status, $terminalStatuses, true)
&& in_array($data['MessageStatus'], $terminalStatuses, true);
if ($hasTerminalConflict) {
$message->update(['status_reconciliation_required' => true]);
ReconcileTwilioMessageStatus::dispatch($message->getKey())
->afterCommit();
return;
}
$currentRank = $rank[$message->status] ?? -1;
$newRank = $rank[$data['MessageStatus']];
if ($newRank > $currentRank || $message->status === $data['MessageStatus']) {
$message->update([
'twilio_sid' => $data['MessageSid'],
'status' => $data['MessageStatus'],
'error_code' => $data['ErrorCode'] ?? null,
]);
}
});
return response()->noContent();
}
}
The reconciliation job should call $twilio->messages($message->twilio_sid)->fetch() with SDK 8.11.6, then lock the local row, copy the fetched status and errorCode, and clear status_reconciliation_required. Give that job normal retry/backoff behavior: unlike Message creation, fetching by SID is safe to retry. This separates an append-only callback history from the current canonical summary instead of silently discarding a terminal outcome or treating a delayed request as newer evidence.
Use Verify for one-time codes
Do not generate or store OTPs yourself. Apply separate request and check rate limits, then accept only an approved status. Verify checks can return 404 after approval, expiry, or maximum attempts.
$twilio->verify->v2
->services(config('services.twilio.verify_service_sid'))
->verifications
->create($phoneE164, 'sms');
$check = $twilio->verify->v2
->services(config('services.twilio.verify_service_sid'))
->verificationChecks
->create(['to' => $phoneE164, 'code' => $code]);
$isApproved = $check->status === 'approved';
Production test matrix
Test valid and invalid signatures, callback URLs behind the production proxy, unknown and mismatched SIDs, duplicate and out-of-order statuses, Twilio API exceptions, ambiguous timeouts, queue failures, opted-out contacts, rate limits, Verify approval and expiry, and STOP/START handling. Use Twilio test credentials where supported, but exercise signed callbacks with fixture signatures in feature tests.
Compliance and abuse controls are application requirements
Store consent source and timestamp, honor opt-outs before every send, register required sender campaigns, use approved WhatsApp templates outside the customer-service window, restrict geographic permissions, set spend alerts, and monitor carrier error codes. Twilio's API does not replace TCPA, GDPR, PECR, or local messaging obligations; obtain legal guidance for the markets you serve.
Integration planning
Plan the Laravel Twilio integration before coding.
Direct answer
A Laravel Twilio integration is production-ready when phone collection, consent, opt-outs, message templates, delivery callbacks, verification status, rate limits, and cost controls are built into the workflow. SMS, WhatsApp, and voice are customer-facing systems, not just notification channels.
Messaging and voice workflows need careful scope because they touch authentication, user consent, delivery cost, compliance, and customer expectations. Somnio plans how Twilio fits into the application before writing notification code, so SMS, WhatsApp, and voice features behave predictably in production.
Data and events model
We map phone-number collection, verification status, notification preferences, message templates, delivery callbacks, and Laravel notification records. That makes it possible to audit what was sent and why.
Failure states
Plan for invalid phone numbers, carrier filtering, failed delivery callbacks, opt-outs, rate limits, cost spikes, WhatsApp template issues, and critical messages that need a fallback channel.
Admin and support visibility
Support needs resend controls, delivery status, opt-out history, message body templates, consent source, and safe permissions for reviewing customer communications.
Provider setup
Setup includes phone numbers or messaging services, webhook URLs, verified sender rules, test numbers, API credentials, opt-out language, and environment variables.
Operations
Twilio integrations need rate limits, opt-out handling, test numbers, failed-message alerts, and cost controls. We also plan how support staff can resend, pause, or review communications safely.
Example production scope
Appointment reminders with verified phone numbers, SMS consent, queued notifications, delivery callbacks, opt-out handling, failed-message alerts, and support resend controls.
Handoff
The delivery includes source code, Twilio console settings, webhook URLs, environment variable notes, test flows, and documentation for adding future message templates or channels.