Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Vonage Notifier Laravel Package

symfony/vonage-notifier

Provides Vonage integration for Symfony Notifier, letting your app send SMS notifications through the Vonage API with no custom transport code. Configure the DSN with your key, secret, and sender: vonage://KEY:SECRET@default?from=FROM. Part of the Symfony Notifier component.

View on GitHub
Deep Wiki
Context7

Getting Started

First, the honest framing: symfony/vonage-notifier is a Symfony Notifier bridge, not a Laravel package. It ships a TransportInterface implementation that talks to Vonage's SMS API and relies on symfony/notifier for the SmsMessage/Recipient abstractions. Laravel has no Notifier component, so "leveraging it day-to-day" means one of two things:

  1. Porting/coexisting with a Symfony codebase — you want the same transport and DSN contract on both sides. Pull symfony/notifier + this bridge in as standalone libraries and adapt them to Laravel's Notification channel system.
  2. You just want Vonage SMS in Laravel — use laravel-notification-channels/vonage or the vonage/client SDK directly. The bridge adds a dependency on Symfony's transport layer for no Laravel-native benefit.

The rest of this assessment assumes path 1, since that's the only case where this package earns its place.

Install:

composer require symfony/vonage-notifier symfony/notifier

This also pulls vonage/client. Pin the bridge major to your PHP version — the 8.x line requires PHP 8.4, 7.x requires 8.2, 6.4 requires 8.1. Most Laravel 10/11 apps will be on ^7.4.

Pin a recent patch, not just the major. v8.1.7 fixes a security bug (#65776, "Verify the payload_hash claim of signed webhooks"). Earlier 8.1.x builds — and, before the backports land, older 7.4.x/6.4.x patches — will accept a signed webhook whose body has been substituted. If you do anything with Vonage callbacks (delivery receipts, inbound SMS), require ^8.1.7 (or the equivalent patched 7.4.x/6.4.x) explicitly rather than floating on ^8.1. Note also that the 8.1 line is now stable — the -BETA suffix that was current at v8.1.0-BETA2 is gone, so the "beta caveat" no longer applies to 8.1.7.

Where to look first (in vendor/symfony/vonage-notifier/):

  • VonageTransport.php — the actual send call, how from is resolved, what exception it throws.
  • VonageTransportFactory.php — constructor dependencies (HTTP client vs. Vonage Client, depending on version) and how the DSN is parsed.
  • The webhook/signature validator shipped alongside the transport — this is where the payload_hash verification lives as of 8.1.7. Read it before you write your own controller; it's the difference between a correct callback endpoint and a forgeable one.
  • vendor/symfony/notifier/Message/SmsMessage.php — the message object you'll construct; note the from argument and options().

Minimum viable send, before wiring anything into Laravel:

use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Notifier\Bridge\Vonage\VonageTransportFactory;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Transport\Dsn;

$transport = (new VonageTransportFactory(HttpClient::create()))->create(
    new Dsn('vonage', 'default', env('VONAGE_KEY'), env('VONAGE_SECRET'), ['from' => 'Acme'])
);

$transport->send(new SmsMessage('Your code is 482913', '+15551234567'));

Check VonageTransportFactory::__construct() in your installed version. Some releases want a Vonage\Client instead of an HTTP client — in that case pass new \Vonage\Client(new \Vonage\Client\Credentials\Basic($key, $secret)).

First real use case: OTP delivery in an auth flow. It's the highest-value, lowest-complexity entry point — one short message, one recipient, high reliability requirement, and it forces you to solve the from/sender-ID question early.

Implementation Patterns

Bind the transport once, in a dedicated provider

Keep credentials in config/services.php (never env() outside config — config caching will bite you):

// config/services.php
'vonage' => [
    'key'    => env('VONAGE_KEY'),
    'secret' => env('VONAGE_SECRET'),
    'from'   => env('VONAGE_FROM', 'Acme'),
    'dsn'    => env('VONAGE_DSN'), // optional full override
],
// app/Providers/NotifierServiceProvider.php
public function register(): void
{
    $this->app->singleton(TransportInterface::class, function () {
        $dsn = config('services.vonage.dsn');

        $dsnObject = $dsn
            ? Dsn::fromString($dsn)
            : new Dsn(
                'vonage',
                'default',
                config('services.vonage.key'),
                config('services.vonage.secret'),
                ['from' => config('services.vonage.from')],
            );

        return (new VonageTransportFactory(HttpClient::create()))->create($dsnObject);
    });
}

Keeping the raw VONAGE_DSN as an override is worth it: it lets ops swap providers or point at a sandbox without a deploy of code, exactly as the README intends.

A Laravel notification channel that delegates

This is the bridge layer that makes the package feel native. It's ~20 lines and is the piece you'll reuse everywhere:

namespace App\Notifications\Channels;

use Illuminate\Notifications\Notification;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Transport\TransportInterface;

class VonageChannel
{
    public function __construct(private TransportInterface $transport) {}

    public function send(object $notifiable, Notification $notification): void
    {
        $text = $notification->toVonage($notifiable);
        if (null === $text) {
            return;
        }

        $phone = $notifiable->routeNotificationFor('vonage', $notification);

        $this->transport->send(new SmsMessage($text, $phone, $notification->from ?? null));
    }
}

Register it so via() can return 'vonage':

// AppServiceProvider::boot()
Notification::extend('vonage', fn ($app) => $app->make(VonageChannel::class));

Then a notification looks identical to any other Laravel notification:

class OneTimeCode extends Notification implements ShouldQueue
{
    public function __construct(private string $code, public ?string $from = null) {}

    public function via($notifiable): array { return ['vonage']; }

    public function toVonage($notifiable): string
    {
        return "Your Acme code is {$this->code}. It expires in 10 minutes.";
    }
}

$user->notify(new OneTimeCode('482913')) — queues, retries, and failed() hooks all come free from Laravel's queue machinery.

Resolve from at the message level, not globally

The DSN's from is a default. Set it per-notification when you need different sender IDs per region or per brand:

$transport->send(new SmsMessage($text, $phone, 'AcmeUS'));

Confirm precedence in VonageTransport::doSend() for your version — message-level from should win over DSN from. If your app sends to multiple countries, you'll want a small resolver (match ($user->country) { 'US' => $longNumber, 'DE' => 'Acme', ... }) rather than one global sender.

Decorate for observability and retries

Because the transport is a clean interface, decorate it instead of touching the bridge:

class LoggingTransport implements TransportInterface
{
    public function __construct(
        private TransportInterface $inner,
        private LoggerInterface $log,
    ) {}

    public function send(MessageInterface $message): SentMessage
    {
        $this->log->info('vonage.send', ['to' => $message->getRecipientId()]);

        $start = microtime(true);
        try {
            return $this->inner->send($message);
        } finally {
            $this->log->info('vonage.sent', ['ms' => (microtime(true) - $start) * 1000]);
        }
    }

    public function supports(MessageInterface $message): bool
    {
        return $this->inner->supports($message);
    }

    public function __toString(): string { return (string) $this->inner; }
}

Bind the decorator as TransportInterface and the raw transport as a named singleton. This is where you'd also add per-country rate limiting or a circuit breaker.

Delivery receipts

The bridge sends; receiving is your route. Delivery status callbacks (DLRs) therefore need your own endpoint — and as of v8.1.7, the signature story changed materially.

Delegate to the bridge's validator. Vonage's signed webhooks are JWTs in the Authorization header. The JWT carries a payload_hash claim that binds the signature to this exact body. Verifying the signature alone is insufficient: an attacker can replay a legitimately-signed request while swapping the body (different message-id, different status). That is precisely the hole closed in #65776.

use Symfony\Component\Notifier\Bridge\Vonage\...; // validator class in your installed version

Route::post('/webhooks/vonage/dlr', function (Request $request) {
    // Verifies the JWT signature AND that the payload_hash claim matches the raw body.
    abort_unless(app(VonageWebhookValidator::class)->validate($request), 403);

    SmsDelivery::updateOrCreate(
        ['message_id' => $request->input('message-id')],
        ['status'     => $request->input('status')],
    );

    return response()->noContent();
});

If the bridge doesn't expose a validator you can inject cleanly, at minimum: parse the JWT, verify the signature with Vonage's public key / shared secret, recompute the payload hash over $request->getContent() (raw body, not the parsed array), and compare with hash_equals().

Keep the legacy form only for the legacy scheme. Older Vonage signature configs use md5(body . secret) in X-Vonage-Signature:

$expected = md5($request->getContent() . config('services.vonage.secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Vonage-Signature')), 403);

Note the hash_equals() — recent releases (v6.4.39 / v7.4.11 / v8.1.0-BETA2) shipped exactly this fix for timing-attack-safe comparison, and v8.1.7 layered payload binding on top. Mirror both properties in any hand-rolled controller: constant-time compare and body binding. == on a signature is a real vulnerability, not a style nit — and a signature check without a payload check is only half the fix.

Testing

Don't hit Vonage in tests. Swap the binding:

$this->app->instance(TransportInterface::class, new class implements TransportInterface {
    public array $sent = [];
    public function send(MessageInterface $m): SentMessage
    {
        $this->sent[] = $m;
        return new SentMessage($m, 'vonage://test');
    }
    public function supports(MessageInterface $m): bool { return $m instanceof SmsMessage; }
    public function __toString(): string { return 'vonage://test'; }
});

Notification::fake() will not intercept your custom transport — assert against your fake (or use Notification::fake() plus a container-swapped transport for belt and braces). Symfony\Component\Notifier\Transport\NullTransport is also available if you only need the send to no-op.

For the webhook side, write two tests: a valid signed payload succeeds, and a valid signature with a mutated body is rejected. That second test is the regression test for #65776 and the one most teams forget to write.

Gotchas and Tips

The sender ID trap. ?from=FROM is the single most common source of "it works in staging, silent in prod." Alphanumeric sender IDs (e.g. Acme) are not deliverable to US/Canada numbers — you need a Vonage virtual long number there. Some countries require pre-registration of alphanumeric IDs. Build the per-country resolution early, and log the from used on every send.

Exception types are Symfony's, not Laravel's. Failures surface as Symfony\Component\Notifier\Exception\TransportExceptionInterface (the bridge wraps Vonage SDK errors). Catch that, not Vonage\Client\Exception\*, if you want portability:

try {
    $transport->send($message);
} catch (TransportExceptionInterface $e) {
    report($e);
    // fall back, or mark the OTP as unsent
}

Never silently swallow a send failure in an OTP flow. Laravel's ShouldQueue will retry, but a failed() handler that logs and forgets means the user waits forever. Fail loudly, or fall back to voice/email.

Webhook trust is a two-part check, and most integrations only did part one. A valid signature proves someone with the secret signed something; the payload_hash claim proves it was this body. If your DLR endpoint was written before v8.1.7, assume it is body-substitutable and fix it — this is the highest-severity item in the current release cycle, above any delivery quirk.

PHP/Symfony version alignment is the second-most-likely install failure. symfony/vonage-notifier ^8.1 demands PHP 8.4; ^7.4 demands 8.2. If you're on Laravel 10 with PHP 8.1, you're pinned to ^6.4 — and you still want the newest 6.4.x for the webhook hardening. Check composer why-not before blaming the package.

The Dsn host is a placeholder. vonage://KEY:SECRET@default?from=FROM — default isn't a host, it's the conventional Symfony transport name. If you build Dsn objects by hand, don't put anything meaningful there and don't be surprised when it's ignored.

No templating, no scheduling, no inbound. The bridge transmits a string. If you need Vonage's message templates, scheduled sends, or inbound SMS handling, you're back to the raw vonage/client SDK for those paths. Keep the bridge for outbound transactional SMS and don't force it beyond that.

Length and encoding cost money. GSM-7 vs UCS-2 segmentation is handled by Vonage, not the bridge. An emoji or a curly apostrophe in your "short" OTP message can triple your bill and split delivery. Keep OTP and alert copy to plain ASCII, under 160 chars, and test with a real handset per target country.

Config caching catches everyone. env('VONAGE_KEY') inside a service provider or channel works in dev and returns null after php artisan config:cache. Everything must read from config(). This is the #1 "but it worked yesterday" bug in Laravel + third-party SDK wiring.

Security patches arrive as patch releases — watch them. Symfony's split repos ship CVE-adjacent fixes as ordinary x.y.z bumps with terse changelog lines. v8.1.7's single line ("Verify the payload_hash claim of signed webhooks") is not a cosmetic change; it's a forgery fix. If you audit dependencies only on majors, you will miss these. Subscribe to the repo's release feed or run a composer outdated --direct check per sprint.

Zero dependents is a signal, but a mild one. This repo is a Symfony monorepo split — it's maintained as part of Symfony, so the empty dependent list reflects the split-repo artifact, not abandonment. Still: the code you write against TransportInterface is the real asset. If you ever swap the bridge for the Vonage SDK or a community package, the channel, the decorator, and the config all survive. Keep the surface that small.

Debugging order when nothing arrives. (1) Log the resolved from and full phone in E.164 — a missing + is the classic. (2) Check storage/logs for TransportExceptionInterface — Vonage returns useful 4xx bodies that the bridge surfaces in the exception message. (3) Confirm the sender ID is legal for the destination country. (4) Only then suspect the bridge itself — it's a thin wrapper over one HTTP call and rarely the problem.

Debugging order when callbacks 403. (1) Raw body — $request->getContent(), not a re-encoded array; a body hash mismatch is usually a json_encode round-trip in the middle. (2) Clock skew on JWT expiry. (3) Verify you're on >= 8.1.7 (or the patched 7.4/6.4) so payload_hash is actually checked, and that you're calling the bridge's validator rather than a stale copy-paste.

Extension points worth knowing: decorate TransportInterface (logging, metrics, retry, rate limit); subclass SmsMessage if you need to carry correlation IDs through to your DLR handler; wrap the webhook validator in a thin app-level class so the controller stays testable and you can add replay protection (nonce/message-id dedupe) on top of the package's guarantee; and use a Symfony EventDispatcher on the Notifier if you ever assemble multiple transports and want MessageEvent hooks — though inside Laravel, a decorator is almost always simpler than wiring Symfony's event system.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support