laravel/vonage-notification-channel
Official Laravel notification channel for sending SMS messages via Vonage (formerly Nexmo). Integrates with Laravel's notification system so you can deliver text alerts to users through a simple, fluent API.
1. Install the channel. It is a transport package, not a full SDK wrapper — it plugs vonage into Laravel's notification system:
composer require laravel/vonage-notification-channel
2. Add credentials. In config/services.php (this package reads from the vonage key, nothing else):
'vonage' => [
'key' => env('VONAGE_KEY'),
'secret' => env('VONAGE_SECRET'),
'sms_from' => env('VONAGE_SMS_FROM'), // purchased number or approved alphanumeric sender
],
3. Tell the notifiable where to send. Add routeNotificationForVonage() returning the phone number:
public function routeNotificationForVonage(Notification $notification): string
{
return $this->phone; // store E.164, e.g. +14155551234
}
4. Write the notification. The channel key is vonage, the method is toVonage(), and it must return a VonageMessage:
use Illuminate\Notifications\Messages\VonageMessage;
public function via(object $notifiable): array
{
return ['vonage'];
}
public function toVonage(object $notifiable): VonageMessage
{
return (new VonageMessage)->content("Your code is {$this->code}. Expires in 10 minutes.");
}
Where to look first. The canonical docs are Laravel's own "SMS Notifications" page (laravel.com/docs/notifications#sms-notifications), not this repo's README — the README is only contribution boilerplate. Then read two files in the package: src/Channels/VonageSmsChannel.php (how the message becomes an API call) and src/Messages/VonageMessage.php (the exact setter surface your installed version supports). The changelog is the best record of behaviour changes, especially the 3.1.x/3.2.x SMS-client rewrites.
First real use case. One-time passwords / login verification is what ~90% of installs use this for. Ship the four steps above plus a queued notification and you have a working flow.
Per-user channel opt-in. Don't hardcode ['vonage']; gate it so users without a phone number or consent never hit the API:
public function via(object $notifiable): array
{
return array_filter([
'vonage' => $notifiable->phone && $notifiable->sms_opt_in,
'mail' => true,
], fn ($v) => $v === true) ? ['vonage', 'mail'] : ['mail'];
}
Simpler and more common: build an array of channel strings conditionally and return it.
Per-message sender override. sms_from is only the default. Use from() for country-specific senders, shortcodes, or alphanumeric IDs:
return (new VonageMessage)
->from(config('services.vonage.sms_from_us')) // e.g. 'AcmeInc'
->content('Your order has shipped.');
On-demand notifications (no user model, e.g. a lead form or admin alert):
Notification::route('vonage', '+14155551234')->notify(new OtpCode($code));
This bypasses routeNotificationForVonage() entirely — useful for smoke tests against a real handset, and for "notify this phone number" flows where you have no notifiable yet.
Queue everything except OTP. Most SMS is ShouldQueue to keep request latency down, but OTP should stay synchronous (or on a dedicated fast queue) so a backed-up worker doesn't add minutes to a login:
class InvoicePaid extends Notification implements ShouldQueue
{
public string $queue = 'notifications';
public int $tries = 3;
public function middleware(): array
{
return [(new RateLimited('sms'))->dontReport()];
}
}
Queued notifications support middleware(), which is the cleanest place to put a RateLimiter so you don't trip Vonage's per-second limits during a broadcast.
Use clientReference() for reconciliation. Set it to your domain key (order or OTP id) and you can match delivery receipts and support tickets back to a record without guessing from timestamps and phone numbers. Keep it short — the upstream API caps the field.
Unicode/forced encoding. For emoji, accents, or non-Latin scripts, force the type explicitly rather than relying on detection:
return (new VonageMessage)->unicode()->content('Código: 123456 ✅');
Testing. Notification::fake() is the correct tool — assert on the message object rather than the wire:
Notification::fake();
$user->notify(new OtpCode('123456'));
Notification::assertSentTo($user, OtpCode::class, function ($notification, $channels) use ($user) {
$message = $notification->toVonage($user);
return str_contains($message->content, '123456');
});
For on-demand sends use Notification::assertSentOnDemand(OtpCode::class, ...). For a true end-to-end test, wrap the whole thing in config(['services.vonage.sms_from' => ...]) and point at a real number once, in a scheduled/manual test — not CI.
Extension points. VonageSmsChannel is bound in the container (since 3.1.0), so you can decorate it rather than fork the package:
$this->app->extend(VonageSmsChannel::class, function ($channel, $app) {
return new LoggingVonageChannel($channel, $app->make(LoggerInterface::class));
});
Or swap the driver entirely with Notification::extend('vonage', fn () => new MyChannel(...)). If you need more than SMS (Verify API, inbound message parsing, number management), install vonage/client alongside and use the SDK directly — this package only sends outbound SMS text.
The nexmo → vonage rename is a breaking migration. In v3 the channel key changed from nexmo to vonage, so toNexmo() and routeNotificationForNexmo() silently stop being called. You'll see the notification "sent" successfully with nothing in via(), or a Notification that never resolves the route. If you're upgrading from laravel/nexmo-notification-channel, remove that package, rename the methods and config key, and grep for 'nexmo' in via() arrays.
config:cache will bite you. Credentials come from services.vonage, which is read at send time from the config repository. After changing env vars in production you must re-run config:cache; a stale cache manifests as "Invalid API key" errors that look like a Vonage problem.
The from value must be provisioned for the destination. A missing sms_from or an unapproved alphanumeric sender produces an API-level rejection, not a PHP error you can guess from the stack trace. Alphanumeric sender IDs (e.g. AcmeInc) are also not deliverable in some regions — most notoriously the US and Canada, where you need a long code or short code. This is the single most common "works in staging, fails in prod" SMS bug.
Http::fake() does not intercept this package. The underlying Vonage SDK speaks over its own Guzzle/PSR-18 stack, not Laravel's HTTP client, so request-level fakes are ignored and your test suite will happily attempt real sends. Fake at the notification layer (Notification::fake()), mock VonageSmsChannel, or inject a fake Vonage\Client — never rely on Http::fake().
Length is money. GSM-7 is 160 chars per segment; unicode drops to 70. Anything past that is concatenated and billed per segment. Set unicode() deliberately, keep messages under one segment, and prefer a short URL over a long one. Emoji in a "short" OTP message can triple the cost.
A null route is not validated. If routeNotificationForVonage() returns null (missing phone, failed cast from an int column), the channel attempts a send anyway and you get an opaque API error. Guard the route method, and prefer storing phone numbers as strings in E.164 — casting +1415... to an integer drops the + and leading digits.
toVonage() must return a VonageMessage. Returning a plain string, or calling ->content() on something that isn't a fresh VonageMessage, throws. VonageMessage is not Stringable, so channel customisations that expect strings need adaptation.
Failures are exceptions on the job, not silent. API errors throw from the channel; in a queued notification that means retries and eventually the failed-jobs table. Use failed() on the notification or listen for NotificationFailed to alert on callbacks, and be careful with $tries on OTP — retrying an SMS five minutes later is worse than not sending it, so cap $tries = 1 and surface the failure to the user instead.
Keep secrets out of logs. It's tempting to dump the full VonageMessage on failure; the message object itself is benign, but the resolved config and any Guzzle exception body can contain your key/secret pair. Redact before logging.
How can I help you explore Laravel packages today?