A simple, unified SMS integration library for Laravel. Send SMS messages through multiple providers with a consistent API, automatic fallback, rate limiting, templating, cost estimation, and analytics.
composer require moffhub/sms-handler
Publish the config and migrations:
php artisan vendor:publish --provider="Moffhub\SmsHandler\SmsHandlerServiceProvider" --tag=sms-config
php artisan vendor:publish --tag=sms-migrations
php artisan migrate
Add the following to your .env file based on your provider:
# Provider selection
SMS_PROVIDER=at # Options: advanta, at, onfon, twilio, nexmo
# Africa's Talking
AT_USERNAME=sandbox # Use 'sandbox' for testing, your app username for production
AT_API_KEY=your_api_key
AT_FROM=YOUR_SENDER_ID # Optional: Your registered sender ID/short code
AT_API_URL= # Optional: Custom API URL (auto-detected based on username)
AT_FALLBACK_PROVIDER= # Optional: Fallback provider name (e.g., 'advanta')
AT_RATE_LIMIT= # Optional: Messages per minute (null = unlimited)
AT_PER_SEGMENT_COST=0.80 # Optional: Cost per SMS segment
# Advanta
ADVANTA_API_KEY=
ADVANTA_API_URL=
ADVANTA_BULK_API_URL=
ADVANTA_PARTNER_ID=
ADVANTA_SHORT_CODE=
ADVANTA_FALLBACK_PROVIDER=
ADVANTA_RATE_LIMIT=
ADVANTA_PER_SEGMENT_COST=1.50
# Onfon Media
ONFON_API_KEY=
ONFON_API_URL=
ONFON_SENDER_ID=
ONFON_CLIENT_ID=
ONFON_FALLBACK_PROVIDER=
ONFON_RATE_LIMIT=
ONFON_PER_SEGMENT_COST=
# Nexmo/Vonage
NEXMO_KEY=
NEXMO_SECRET=
NEXMO_FROM=NEXMO
NEXMO_API_URL=https://rest.nexmo.com/sms/json
NEXMO_FALLBACK_PROVIDER=
NEXMO_RATE_LIMIT=
NEXMO_PER_SEGMENT_COST=
# Twilio
TWILIO_SID=
TWILIO_TOKEN=
TWILIO_FROM=
TWILIO_API_URL=https://api.twilio.com
TWILIO_FALLBACK_PROVIDER=
TWILIO_RATE_LIMIT=
TWILIO_PER_SEGMENT_COST=
# Logging
SMS_LOG_CHANNEL=log # Options: log, model
SMS_STRUCTURED_LOG_CHANNEL= # Optional: Separate log channel for structured SMS logs
# Webhooks
SMS_WEBHOOKS_ENABLED=false
SMS_WEBHOOKS_PREFIX=sms/webhooks
SMS_WEBHOOKS_RATE_LIMIT=60
SMS_WEBHOOK_SECRET_ADVANTA=
SMS_WEBHOOK_SECRET_AFRICASTALKING=
SMS_WEBHOOK_SECRET_ONFON=
SMS_WEBHOOK_SECRET_NEXMO=
SMS_WEBHOOK_SECRET_TWILIO=
# Queue
SMS_QUEUE_NAME=default
SMS_QUEUE_TIMEOUT=30
SMS_QUEUE_MAX_TRIES=3
use Moffhub\SmsHandler\Facades\Sms;
// Send a single SMS
Sms::sendSms('+254712345678', 'Hello World');
// Send bulk SMS
Sms::sendBulkSms(['+254712345678', '+254712345679'], 'Hello everyone!');
// Send scheduled SMS
Sms::sendScheduledSms('+254712345678', 'Reminder!', '2024-12-25 09:00:00');
// Check delivery status
$status = Sms::getSmsDeliveryStatus('message_id_here');
use Moffhub\SmsHandler\Services\SmsService;
class NotificationController extends Controller
{
public function __construct(protected SmsService $smsService) {}
public function notify(Request $request)
{
$this->smsService->sendSms(
$request->phone,
$request->message
);
}
}
use Moffhub\SmsHandler\SmsManager;
$manager = app(SmsManager::class);
// Use Africa's Talking for this message
$manager->driver('at')->sendSms('+254712345678', 'Via AT');
// Use Twilio for this message
$manager->driver('twilio')->sendSms('+1234567890', 'Via Twilio');
Configure a fallback provider that is used automatically when the primary provider fails with a ProviderException. Fallback is limited to one level (no chaining beyond the fallback).
# In .env
ADVANTA_FALLBACK_PROVIDER=africastalking
Or in config/sms.php:
'providers' => [
'advanta' => [
// ...credentials...
'fallback' => 'africastalking',
],
],
When the primary provider fails, the package will:
Non-ProviderException errors (e.g., validation errors) are not retried and are rethrown.
Configure per-provider rate limits (messages per minute). When the limit is exceeded, messages are automatically queued for later delivery instead of being rejected.
# In .env - allow 100 messages per minute for Advanta
ADVANTA_RATE_LIMIT=100
Or in config/sms.php:
'providers' => [
'advanta' => [
// ...credentials...
'rate_limit' => 100, // messages per minute, null = unlimited
],
],
Programmatic access:
use Moffhub\SmsHandler\Facades\Sms;
// Check remaining attempts
$remaining = Sms::rateLimiter()->remainingAttempts('advanta');
// Clear the rate limiter for a provider
Sms::rateLimiter()->clear('advanta');
null (unlimited) only for providers with no known API rate limits.Define reusable SMS templates with {{ variable }} interpolation:
// config/sms.php
'templates' => [
'otp' => 'Your verification code is {{ code }}. Valid for {{ minutes }} minutes.',
'welcome' => 'Welcome {{ name }}! Thanks for joining us.',
'order_shipped' => ['body' => 'Hi {{ name }}, your order #{{ order_id }} has shipped.'],
],
Send a templated SMS:
use Moffhub\SmsHandler\Facades\Sms;
// Fluent API
Sms::template('otp', ['code' => '1234', 'minutes' => '5'])
->to('+254712345678')
->send();
// Check if a template exists
Sms::templateService()->exists('otp'); // true
// Get all template names
Sms::templateService()->getTemplateNames(); // ['otp', 'welcome', 'order_shipped']
Message length is validated after interpolation. If the rendered message exceeds the configured max_message_length, an InvalidMessageException is thrown.
Estimate the cost of sending an SMS before dispatching. The estimator calculates SMS segment count based on message encoding (GSM-7 vs UCS-2) and multiplies by the configured per-segment cost.
use Moffhub\SmsHandler\Facades\Sms;
$estimate = Sms::estimateCost('Hello world', 100, 'advanta');
// Returns:
// [
// 'segments' => 1,
// 'per_segment_cost' => 1.50,
// 'total_cost' => 150.0,
// 'recipient_count' => 100,
// 'is_unicode' => false,
// ]
| Encoding | Single SMS | Multi-part (per segment) |
|---|---|---|
| GSM-7 | 160 chars | 153 chars |
| UCS-2 | 70 chars | 67 chars |
Unicode characters (emoji, CJK, Arabic, etc.) force UCS-2 encoding, which reduces the per-segment capacity.
Configure per-segment cost in your provider config:
'providers' => [
'advanta' => [
// ...credentials...
'per_segment_cost' => 1.50,
],
],
When logging to the database (SMS_LOG_CHANNEL=model), the estimated_cost and segment_count columns are automatically populated on each SmsLog record.
Query SMS analytics aggregated from the sms_logs table:
use Moffhub\SmsHandler\Facades\Sms;
// Overall summary
$summary = Sms::analytics()->summary();
// ['total_sent' => 1000, 'total_delivered' => 950, 'total_failed' => 50, 'success_rate' => 95.0, ...]
// Filter by provider
$summary = Sms::analytics()->forProvider('twilio')->summary();
// Filter by date range
$summary = Sms::analytics()->last30Days()->summary();
$summary = Sms::analytics()->last7Days()->summary();
$summary = Sms::analytics()->between($from, $to)->summary();
// Combine filters
$summary = Sms::analytics()->forProvider('advanta')->last30Days()->summary();
// Daily breakdown
$breakdown = Sms::analytics()->last30Days()->dailyBreakdown();
// Collection of ['date' => '2024-01-15', 'sent' => 100, 'delivered' => 95, 'failed' => 5]
// Per-provider summary
$providers = Sms::analytics()->perProviderSummary();
// Collection of ['provider' => 'advanta', 'sent' => 500, 'delivered' => 490, 'failed' => 10, 'success_rate' => 98.0]
View SMS statistics from the command line:
# Last 30 days (default)
php artisan sms:stats
# Last 7 days
php artisan sms:stats --days=7
# Filter by provider
php artisan sms:stats --provider=advanta
# Combined
php artisan sms:stats --provider=twilio --days=14
The package defines a hierarchy of exceptions:
| Exception | Description |
|---|---|
SmsException |
Base exception class for all SMS errors |
ProviderException |
Provider-level failures (API errors, timeouts). Triggers fallback if configured. |
InvalidPhoneNumberException |
Invalid, empty, or malformed phone numbers |
InvalidMessageException |
Empty or too-long messages |
use Moffhub\SmsHandler\Exceptions\ProviderException;
use Moffhub\SmsHandler\Exceptions\InvalidPhoneNumberException;
use Moffhub\SmsHandler\Exceptions\InvalidMessageException;
try {
Sms::sendSms($phone, $message);
} catch (InvalidPhoneNumberException $e) {
// Phone number validation failed
// e.g., "Invalid phone number '123': Phone number must have at least 9 digits"
} catch (InvalidMessageException $e) {
// Message validation failed
// e.g., "SMS message cannot be empty"
// e.g., "Message exceeds maximum of 918 characters"
} catch (ProviderException $e) {
// Provider API error — fallback was already attempted if configured
// e.g., "SMS provider 'advanta' failed to send: Connection timeout"
}
Listen for SMS lifecycle events:
use Moffhub\SmsHandler\Events\SmsSent;
use Moffhub\SmsHandler\Events\SmsFailed;
use Moffhub\SmsHandler\Events\DeliveryReportReceived;
// In EventServiceProvider
protected $listen = [
SmsSent::class => [SmsSuccessListener::class],
SmsFailed::class => [SmsFailureListener::class],
DeliveryReportReceived::class => [DeliveryReportListener::class],
];
// SmsSent carries: provider, to, message, messageId, response
// SmsFailed carries: provider, to, message, exception
// DeliveryReportReceived carries: provider, messageId, status, phoneNumber, payload
Enable webhooks to receive delivery reports from providers:
SMS_WEBHOOKS_ENABLED=true
SMS_WEBHOOKS_PREFIX=sms/webhooks # URL prefix
This registers POST routes for each provider:
| Provider | Endpoint | Route Name |
|---|---|---|
| Advanta | POST /sms/webhooks/advanta |
sms.webhooks.advanta |
| Africa's Talking | POST /sms/webhooks/africastalking |
sms.webhooks.africastalking |
| Onfon | POST /sms/webhooks/onfon |
sms.webhooks.onfon |
| Nexmo | POST /sms/webhooks/nexmo |
sms.webhooks.nexmo |
| Twilio | POST /sms/webhooks/twilio |
sms.webhooks.twilio |
Set webhook secrets to validate incoming requests:
SMS_WEBHOOK_SECRET_TWILIO=your_twilio_auth_token
SMS_WEBHOOK_SECRET_AFRICASTALKING=your_callback_token
SMS_WEBHOOK_SECRET_ADVANTA=your_shared_secret
SMS_WEBHOOK_SECRET_NEXMO=your_signature_secret
SMS_WEBHOOK_SECRET_ONFON=your_api_key
If a secret is configured, the package validates the signature header on incoming webhook requests. Invalid signatures receive a 403 response.
Twilio:
https://yourdomain.com/sms/webhooks/twilioAfrica's Talking:
https://yourdomain.com/sms/webhooks/africastalkingAdvanta:
https://yourdomain.com/sms/webhooks/advantaNexmo/Vonage:
https://yourdomain.com/sms/webhooks/nexmoOnfon:
https://yourdomain.com/sms/webhooks/onfonTwilio:
{
"MessageSid": "SM1234567890",
"MessageStatus": "delivered",
"To": "+254712345678",
"ErrorCode": null
}
Africa's Talking:
{
"id": "ATXid_123",
"status": "Success",
"phoneNumber": "+254712345678",
"failureReason": ""
}
Advanta:
{
"messageId": "msg123",
"status": "DeliveredToTerminal",
"phoneNumber": "254712345678"
}
Nexmo/Vonage:
{
"messageId": "0C000000217B7F02",
"status": "delivered",
"to": "254712345678",
"err-code": "0"
}
Onfon:
{
"MessageId": "12345",
"Status": "DELIVERED",
"Number": "254712345678"
}
The library fully supports the Africa's Talking Bulk SMS API:
AT_USERNAME=sandbox
AT_API_KEY=your_sandbox_api_key
AT_USERNAME=your_app_username
AT_API_KEY=your_production_api_key
AT_FROM=YOUR_SENDER_ID
Create your own provider by extending CustomProvider:
use Moffhub\SmsHandler\Providers\CustomProvider;
use Illuminate\Support\Collection;
class MySmsProvider extends CustomProvider
{
protected function getApiUrl(): string
{
return 'https://api.custom.com/send';
}
protected function buildPayload(string $to, string $message): array
{
return [
'to' => $to,
'text' => $message,
'api_key' => $this->config['key'],
];
}
protected function handleResponse(mixed $response): ?Collection
{
return collect([
'status' => $response['status'] ?? 'unknown',
]);
}
}
Register your provider:
// In a service provider
use Moffhub\SmsHandler\SmsManager;
$this->app->make(SmsManager::class)->extend('custom', function ($app) {
return new MySmsProvider([
'key' => config('sms.providers.custom.key'),
]);
});
Add config:
// config/sms.php
'providers' => [
'custom' => [
'key' => env('MY_CUSTOM_API_KEY'),
],
],
Update .env:
SMS_PROVIDER=custom
MY_CUSTOM_API_KEY=super-secret
Use SMS in Laravel notifications:
use Moffhub\SmsHandler\Notifications\SmsChannel;
class OrderShipped extends Notification
{
public function via($notifiable): array
{
return [SmsChannel::class];
}
public function toSms($notifiable): string
{
return 'Your order has been shipped!';
}
}
Ensure your notifiable model has a routeNotificationForSms method:
public function routeNotificationForSms(): string
{
return $this->phone;
}
SMS messages can be logged to file or database:
# Log to Laravel's log file
SMS_LOG_CHANNEL=log
# Log to database (requires migration)
SMS_LOG_CHANNEL=model
When using database logging, each SmsLog record includes:
provider - The provider class usedto - Recipient phone numbermessage - Message contentsuccess - Boolean success statusmessage_id - Provider message IDdelivery_status - Updated via webhooksestimated_cost - Calculated cost based on segments and provider ratesegment_count - Number of SMS segmentsscheduled_at - For scheduled messagesresponse - Raw provider responseThe package logs structured events to a configurable log channel:
SMS_STRUCTURED_LOG_CHANNEL=sms # Optional: dedicated log channel
Log events include:
sms.sent - Successful send with provider, recipient, message_idsms.failed - Failed send with provider, recipient, error detailssms.bulk_failed - Bulk send failuresms.delivery_report - Incoming delivery reportAll log entries are scrubbed of sensitive data (API keys, tokens, secrets).
Use Laravel's HTTP faking to test SMS sending without making real API calls:
use Illuminate\Support\Facades\Http;
use Moffhub\SmsHandler\Facades\Sms;
Http::fake([
'*' => Http::response([
'responses' => [
[
'response-code' => 200,
'response-description' => 'Success',
'mobile' => '254712345678',
'messageid' => 'msg123',
],
],
]),
]);
Sms::sendSms('+254712345678', 'Test message');
Http::assertSentCount(1);
Test that events are dispatched correctly:
use Illuminate\Support\Facades\Event;
use Moffhub\SmsHandler\Events\SmsSent;
use Moffhub\SmsHandler\Events\SmsFailed;
Event::fake([SmsSent::class, SmsFailed::class]);
Sms::sendSms('+254712345678', 'Test');
Event::assertDispatched(SmsSent::class, function ($event) {
return $event->to === '+254712345678';
});
use Moffhub\SmsHandler\Services\TemplateService;
$service = new TemplateService();
$rendered = $service->render('otp', ['code' => '1234']);
$this->assertEquals('Your code is 1234.', $rendered);
use Moffhub\SmsHandler\Facades\Sms;
$estimate = Sms::estimateCost('Short message', 1, 'advanta');
$this->assertEquals(1, $estimate['segments']);
$this->assertEquals(1.50, $estimate['total_cost']);
SMS not sending:
.envSMS_PROVIDER is set to a valid provider nameSMS_LOG_CHANNEL=log and review Laravel logs for errorsValidation errors:
SMS_MAX_MESSAGE_LENGTH (default: 918 characters)0712345678, 254712345678, +254712345678Rate limiting:
rate_limit config per providerSms::rateLimiter()->remainingAttempts('provider') to check remaining quotaSms::rateLimiter()->clear('provider')Webhooks not receiving reports:
SMS_WEBHOOKS_ENABLED=trueFallback not activating:
ProviderException, not on validation errorsCost estimation showing 0:
per_segment_cost is configured for the provider in config/sms.phpEnable debug-level structured logging:
// config/logging.php
'channels' => [
'sms' => [
'driver' => 'daily',
'path' => storage_path('logs/sms.log'),
'level' => 'debug',
],
],
SMS_STRUCTURED_LOG_CHANNEL=sms
composer test
MIT License. See LICENSE for details.
How can I help you explore Laravel packages today?