Laravel SMS: When Email Isn't Urgent Enough

SS Saurav Sitaula

Some notifications can't wait for users to check their inbox. Learn to integrate SMS into Laravel with Nexmo/Vonage and Twilio, handle delivery status, manage costs, and decide when SMS is actually worth it.

The “Check Your Email” Problem

After implementing email notifications, I had a problem: critical alerts were getting lost.

Password reset? Users didn’t see the email for hours. Order ready for pickup? Customers showed up the next day. Payment failed? Users had no idea until their subscription ended.

Email open rates are around 20%. SMS? Over 90%, usually within 3 minutes.

When something is urgent, email isn’t enough.

Choosing an SMS Provider

In 2019, the main options were:

Nexmo (now Vonage)

  • Laravel had an official package
  • Good global coverage
  • Pay-per-message pricing

Twilio

  • More features (voice, video, WhatsApp)
  • Better documentation
  • Slightly more expensive

I went with Nexmo for the official Laravel integration, but later added Twilio for some projects.

Setting Up Nexmo

composer require laravel/nexmo-notification-channel

Get API credentials from Nexmo dashboard:

NEXMO_KEY=your_api_key
NEXMO_SECRET=your_api_secret
NEXMO_SMS_FROM=MyApp

The NEXMO_SMS_FROM can be a phone number or an alphanumeric sender ID (like “MyApp”). Alphanumeric IDs look more professional but can’t receive replies.

Basic SMS Notification

Building on my notification class from before:

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\NexmoMessage;

class PasswordResetCode extends Notification
{
    protected $code;

    public function __construct($code)
    {
        $this->code = $code;
    }

    public function via($notifiable)
    {
        return ['nexmo'];
    }

    public function toNexmo($notifiable)
    {
        return (new NexmoMessage)
            ->content('Your password reset code is: ' . $this->code . '. Valid for 10 minutes.');
    }
}

The User model needs to provide the phone number:

// app/User.php
class User extends Authenticatable
{
    // ...
    
    public function routeNotificationForNexmo($notification)
    {
        return $this->phone;
    }
}

Send it:

$code = rand(100000, 999999);
$user->notify(new PasswordResetCode($code));

SMS + Email Fallback

What if the user doesn’t have a phone number? Fall back to email:

public function via($notifiable)
{
    if ($notifiable->phone) {
        return ['nexmo', 'mail'];  // Both for redundancy
    }
    
    return ['mail'];  // Email only
}

public function toMail($notifiable)
{
    return (new MailMessage)
        ->subject('Password Reset Code')
        ->line('Your password reset code is: ' . $this->code)
        ->line('Valid for 10 minutes.');
}

Using Twilio Instead

Some projects required Twilio. The setup is similar but manual:

composer require twilio/sdk

Create a custom channel:

// app/Channels/TwilioChannel.php
<?php

namespace App\Channels;

use Illuminate\Notifications\Notification;
use Twilio\Rest\Client;

class TwilioChannel
{
    protected $client;
    protected $from;

    public function __construct()
    {
        $this->client = new Client(
            config('services.twilio.sid'),
            config('services.twilio.token')
        );
        $this->from = config('services.twilio.from');
    }

    public function send($notifiable, Notification $notification)
    {
        $message = $notification->toTwilio($notifiable);
        $to = $notifiable->routeNotificationFor('twilio');

        if (!$to) {
            return;
        }

        return $this->client->messages->create($to, [
            'from' => $this->from,
            'body' => $message->content,
        ]);
    }
}

Config in config/services.php:

'twilio' => [
    'sid' => env('TWILIO_SID'),
    'token' => env('TWILIO_AUTH_TOKEN'),
    'from' => env('TWILIO_FROM'),
],

Use in notifications:

public function via($notifiable)
{
    return [TwilioChannel::class];
}

public function toTwilio($notifiable)
{
    return new TwilioMessage('Your code is: ' . $this->code);
}

Handling International Numbers

Phone numbers are tricky. Users enter them in all formats:

(555) 123-4567
555-123-4567
+1 555 123 4567
5551234567

Normalize them:

// app/User.php
public function setPhoneAttribute($value)
{
    // Remove all non-numeric characters
    $phone = preg_replace('/[^0-9+]/', '', $value);
    
    // Add country code if missing (assuming US)
    if (!str_starts_with($phone, '+')) {
        $phone = '+1' . $phone;
    }
    
    $this->attributes['phone'] = $phone;
}

public function routeNotificationForNexmo($notification)
{
    return $this->phone;  // Already normalized
}

Better yet, use a library:

composer require propaganistas/laravel-phone
use Propaganistas\LaravelPhone\PhoneNumber;

$phone = PhoneNumber::make($request->phone, 'US')->formatE164();
// Returns: +15551234567

Cost Management

SMS costs money. At $0.0075 per message (Nexmo US), 10,000 messages = $75. Strategies to manage costs:

1. Only SMS for Critical Alerts

public function via($notifiable)
{
    // Always email
    $channels = ['mail', 'database'];
    
    // SMS only for critical
    if ($this->isCritical()) {
        $channels[] = 'nexmo';
    }
    
    return $channels;
}

private function isCritical()
{
    return in_array($this->type, [
        'payment_failed',
        'security_alert',
        'order_ready',
    ]);
}

2. User Preferences

// Migration
$table->boolean('sms_notifications')->default(false);
$table->boolean('sms_marketing')->default(false);

// Check before sending
public function via($notifiable)
{
    $channels = ['mail'];
    
    if ($notifiable->sms_notifications && $notifiable->phone) {
        $channels[] = 'nexmo';
    }
    
    return $channels;
}

3. Daily Limits

// Check daily SMS count before sending
public function via($notifiable)
{
    $channels = ['mail'];
    
    $todaySmsCount = $notifiable->notifications()
        ->where('type', 'sms')
        ->whereDate('created_at', today())
        ->count();
    
    if ($todaySmsCount < 5 && $notifiable->phone) {
        $channels[] = 'nexmo';
    }
    
    return $channels;
}

Delivery Status Webhooks

Did the SMS actually arrive? Set up webhooks:

// routes/api.php
Route::post('/webhooks/nexmo', 'WebhookController@nexmo');
// app/Http/Controllers/WebhookController.php
public function nexmo(Request $request)
{
    $messageId = $request->input('messageId');
    $status = $request->input('status');  // delivered, failed, etc.
    
    SmsLog::where('message_id', $messageId)->update([
        'status' => $status,
        'delivered_at' => $status === 'delivered' ? now() : null,
    ]);
    
    return response('OK');
}

Track all outgoing SMS:

// Custom channel that logs
public function send($notifiable, Notification $notification)
{
    $message = $notification->toNexmo($notifiable);
    $to = $notifiable->routeNotificationFor('nexmo');

    $response = $this->nexmo->message()->send([
        'to' => $to,
        'from' => config('services.nexmo.from'),
        'text' => $message->content,
    ]);

    // Log for tracking
    SmsLog::create([
        'user_id' => $notifiable->id,
        'to' => $to,
        'message' => $message->content,
        'message_id' => $response->current()->getMessageId(),
        'status' => 'sent',
    ]);
}

OTP / 2FA with SMS

A common use case — two-factor authentication:

// Generate and store OTP
public function sendOtp(User $user)
{
    $otp = rand(100000, 999999);
    
    Cache::put('otp_' . $user->id, $otp, now()->addMinutes(10));
    
    $user->notify(new OtpNotification($otp));
}

// Verify OTP
public function verifyOtp(Request $request)
{
    $user = auth()->user();
    $cachedOtp = Cache::get('otp_' . $user->id);
    
    if ($cachedOtp && $cachedOtp == $request->otp) {
        Cache::forget('otp_' . $user->id);
        // Mark as verified, proceed with action
        return redirect('/dashboard');
    }
    
    return back()->withErrors(['otp' => 'Invalid or expired code']);
}

The notification:

class OtpNotification extends Notification
{
    protected $otp;

    public function __construct($otp)
    {
        $this->otp = $otp;
    }

    public function via($notifiable)
    {
        return ['nexmo'];
    }

    public function toNexmo($notifiable)
    {
        return (new NexmoMessage)
            ->content('Your verification code is: ' . $this->otp . '. Do not share this code.');
    }
}

Testing SMS

Don’t send real SMS in tests. Mock the channel:

// tests/Feature/SmsTest.php
use Illuminate\Support\Facades\Notification;

public function test_password_reset_sends_sms()
{
    Notification::fake();
    
    $user = factory(User::class)->create(['phone' => '+15551234567']);
    
    $user->notify(new PasswordResetCode('123456'));
    
    Notification::assertSentTo($user, PasswordResetCode::class, function ($notification, $channels) {
        return in_array('nexmo', $channels);
    });
}

For manual testing, use test phone numbers from your provider or a service like Mailtrap for SMS (yes, that exists now).

Common Gotchas

1. Phone Number Format

Error: Invalid 'to' address

Always use E.164 format: +15551234567. No spaces, dashes, or parentheses.

2. Sender ID Restrictions

Some countries don’t allow alphanumeric sender IDs. You must use a real phone number. Check your provider’s documentation.

3. Rate Limits

Providers limit how fast you can send:

// Add delay between batch SMS
$users->each(function ($user, $index) use ($notification) {
    dispatch(function () use ($user, $notification) {
        $user->notify($notification);
    })->delay(now()->addSeconds($index)); // 1 per second
});

4. Opt-Out Compliance

In many countries, users must be able to opt out. Handle “STOP” replies:

public function handleIncomingSms(Request $request)
{
    $from = $request->input('from');
    $text = strtoupper(trim($request->input('text')));
    
    if ($text === 'STOP') {
        User::where('phone', $from)->update(['sms_notifications' => false]);
    }
}

What I Wish I’d Known Earlier

  1. SMS is expensive. Calculate costs before implementing. Reserve for truly critical notifications.

  2. Phone validation is hard. Use a library. Users enter numbers in wild formats.

  3. International SMS is tricky. Different countries have different rules, prices, and sender ID requirements.

  4. Queue your SMS. API calls are slow. Always use queued notifications.

  5. Test with real phones. Simulator testing misses real-world issues like carrier delays and formatting.

The Journey Continues

Email, SMS, database notifications — users could be reached anywhere. But I still had a gap.

My app was Blade templates. What about mobile apps? What about React frontends? They needed APIs, not HTML.

Time to build a proper REST API alongside my Blade views.


P.S. — The first time a user called to thank us because an SMS alert saved them from a missed payment deadline, I understood why SMS matters. It’s expensive, yes. But for the right notifications, it’s worth every cent.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism