All posts

/4 min/Moffat Munene

An SMS layer that survives provider outages moffhub/sms-handler

Multi-provider SMS for Laravel Africa's Talking, Advanta, Onfon, Twilio and Nexmo behind one API, with automatic fallback, per-provider rate limiting that auto-queues, segment-aware cost estimation, and webhook-driven delivery tracking.

  • #laravel
  • #sms
  • #africa
  • #mpesa
  • #otp
  • #observability

Every Laravel app we ship sends SMS OTPs, payment confirmations, fee reminders, USSD callbacks. We used to write the same wrapper around the same provider four times a year, watch one outage take production down, and add a "TODO: handle Twilio fallback" we never came back to.

moffhub/sms-handler is the wrapper, written once, hardened for the things that actually go wrong.

One facade, five providers

use Moffhub\Sms\Facades\Sms;

Sms::sendSms('+254712345678', 'Your OTP is 482910. Valid 5 minutes.');

That dispatches via the configured default provider Africa's Talking, Advanta, Onfon, Twilio or Nexmo. Switch at runtime if you want:

Sms::sendSms($to, $message, provider: 'twilio');

Each implements the same SmsProviderInterface (send, sendBulk, getStatus, refund, verifyWebhook). Adding a sixth Wave, Hubtel, Termii, the next thing is a class, not a fork.

Automatic fallback when a provider falls over

This is the feature that earns the package's keep. Africa's Talking goes down for an hour every few months. Advanta's API decides to start returning 502s for one specific shortcode at 2am. Configure a fallback:

'providers' => [
    'advanta' => [
        // ... credentials ...
        'fallback' => 'africastalking',
    ],
],

When Advanta throws ProviderException, the package logs the failure, retries with Africa's Talking, and returns the result to your code. Your controller doesn't know a fallback happened. Your customer's OTP arrived from a different shortcode but it arrived.

Fallback only triggers on ProviderException (network failure, 5xx, timeout). Validation errors, missing config, malformed numbers those still raise. We want resilience, not silent corruption.

Cost estimation before the bulk send

If you're sending to 5,000 customers, you want to know what it costs before the loop runs.

$estimate = Sms::estimateCost($message, recipientCount: 5000, provider: 'advanta');

// [
//   'segments' => 2,
//   'per_segment_cost' => 1.50,
//   'total_cost' => 15000.00,
//   'recipient_count' => 5000,
//   'is_unicode' => false,
// ]

The estimator handles GSM-7 vs UCS-2 segmentation correctly (single SMS: 160 GSM-7 chars, but 70 UCS-2; multipart: 153/67). Drop a 你 in your "Hello" and the cost doubles the estimator catches it.

Show the cost in admin before the user clicks "Send Campaign." Multi-tenant SaaS? Bill the cost to the tenant before dispatch.

Rate limiting that auto-queues

Every provider has a rate ceiling. Hit it and the naïve thing happens your app errors out, the customer-success agent sends a Slack message, a developer runs a Tinker script later that night to retry the failed batch.

The package's rate limiter handles it differently:

'providers.advanta.rate_limit' => 100, // per minute

Send 250 SMSes in one call. The first 100 dispatch immediately. The remaining 150 enqueue as SendSmsJob. They send when the bucket refills. No errors. No babysitting.

Templates with variable interpolation

'templates' => [
    'otp' => 'Your code is {{ code }}. Valid {{ minutes }} minutes.',
    'fee_due' => 'Hi {{ name }}, your school fees ({{ amount }}) are due {{ date }}.',
],
Sms::template('fee_due', [
    'name' => $parent->name,
    'amount' => 'KES 4,500',
    'date' => '2026-05-15',
])->send($parent->phone);

The template service validates the rendered message length before dispatch a 200-character template that accidentally renders to 950 characters won't quietly become a six-segment send.

Delivery tracking via webhooks

OTP delivered or stuck in a queue? Without delivery reports you can't tell. The package auto-registers signed webhook endpoints per provider:

POST /sms/webhooks/africastalking
POST /sms/webhooks/twilio
POST /sms/webhooks/advanta
POST /sms/webhooks/onfon
POST /sms/webhooks/nexmo

Each provider's HMAC scheme is verified before the payload is parsed. Delivery callbacks update sms_logs.delivery_status (pending → delivered | failed | unknown) and timestamp delivered_at. Your analytics queries reflect live delivery not "we successfully called the API."

Analytics for free

$summary = Sms::analytics()
    ->forProvider('advanta')
    ->last30Days()
    ->summary();

// [
//   'total_sent' => 12_400,
//   'total_delivered' => 12_001,
//   'total_failed' => 399,
//   'success_rate' => 0.9678,
//   ...
// ]

Compare providers, drill into days, expose to a finance dashboard. There's also php artisan sms:stats --days=30 --provider=advanta for the terminal-first crowd.

Notification channel

For the times you want stock Laravel notifications:

class OtpNotification extends Notification {
    public function via($notifiable): array { return ['sms']; }
    public function toSms($notifiable): string { return "Your OTP is {$this->code}"; }
}

$user->notify(new OtpNotification($code));

The Notifiable model implements routeNotificationForSms(); the channel handles the rest, fallback chain included.

Logs that won't get you fired

Every send writes a row in sms_logs provider, recipient, message, success, message ID, delivery status, estimated cost, segment count, scheduled-at, sent-at, delivered-at, response JSON. API keys, secrets, and tokens are scrubbed from the response and context fields automatically. Forward to ELK or Datadog without paranoia.

Events fired

  • SmsSent successful dispatch
  • SmsFailed exhausted (after fallback)
  • DeliveryReportReceived webhook arrived

Listen and integrate your CRM, your in-app inbox, your SLA dashboard.

Install

composer require moffhub/sms-handler
php artisan vendor:publish --tag=sms-handler-config
php artisan migrate

Set the provider env vars, set the fallback, ship. The next time Africa's Talking has a bad afternoon, your OTP delivery rate stays the same.

Package for this post

Unified SMS for Laravel

Africa's Talking, Advanta, Onfon, Twilio, Nexmo behind one API. Queue-ready, fallback chains, delivery tracking, templating, analytics.

composer require moffhub/sms-handler