tomatophp/messagebird-notifications
Laravel notification channel for MessageBird, letting you send SMS notifications through MessageBird's API using the familiar Laravel notification system. Configure your credentials and start delivering messages to users in minutes.
The package has no documentation, so treat the source as the spec and budget your first hour for reading, not coding. Install it, then immediately answer the three questions that determine whether the rest of your day is integration or rebuild:
composer require tomatophp/messagebird-notifications
composer show tomatophp/messagebird-notifications # php + laravel constraints
composer show messagebird/php-rest-api # transitive SDK version
If the Laravel constraint is 5.x-only, stop here. An SMS channel adapter is roughly 150–250 lines; backporting a 5.x package to a modern Laravel is usually more work than writing your own driver against the MessageBird SDK. That math is the single most important decision in this whole assessment.
Read these files in this order — each one answers a question you will otherwise debug at runtime:
composer.json — PHP/Laravel range, SDK constraint, and extra.laravel.providers (does it auto-discover?).src/*ServiceProvider.php — does it call Channel::extend('messagebird', ...)? That decides whether via() takes a string or a class name.src/Channels/*.php — which method it calls on your notification (toMessageBird()?), and what shape it expects back.src/Messages/*.php — the builder class, its constructor, and available setters.config() or raw env().Publish and configure:
php artisan vendor:publish --provider="TomatoPHP\MessageBirdNotifications\MessageBirdNotificationsServiceProvider"
php artisan config:clear
MESSAGEBIRD_ACCESS_KEY=live_xxxxxxxxxxxxxxxxxxxx
MESSAGEBIRD_ORIGINATOR=+15550001111
Use --provider, not --tag — tag names are undocumented. Before sending anything, confirm the values actually resolve through config() inside a tinker session. If the package reads env() directly in src/, everything works locally and returns null the moment you deploy with config:cache.
First use case: a single OTP. It is the smallest thing that exercises the whole path — routing, credential resolution, originator, delivery to a real handset — with an unambiguous pass/fail outcome.
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use TomatoPHP\MessageBirdNotifications\Channels\MessageBirdChannel;
use TomatoPHP\MessageBirdNotifications\Messages\MessageBirdMessage;
class OtpCode extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public string $code) {}
public function via($notifiable): array
{
// Use the CLASS NAME unless the service provider registered a driver.
return [MessageBirdChannel::class];
}
public function toMessageBird($notifiable)
{
return MessageBirdMessage::create()
->content("Your verification code is {$this->code}. Expires in 10 minutes.");
}
public function routeNotificationForMessageBird($notifiable)
{
return $notifiable->phone_e164; // E.164 only: +15551234567
}
}
Fire one real message from php artisan tinker against a non-production key before you build anything on top of it. If that works, the day-to-day surface of this package is genuinely small — the rest of this document is about the edges around it.
Pin down routing first — it is where nearly every silent failure lives. There are three routing surfaces and they must agree on the same key:
// 1. On the Notifiable model — explicit, testable, preferred.
public function routeNotificationForMessageBird($notifiable)
{
return $this->phone_e164;
}
// 2. On-demand — for anonymous recipients (invites, ops alerts to non-users).
Notification::route(MessageBirdChannel::class, '+15551234567')
->notify(new OrderShipped($order));
// 3. Implicit fallback — Laravel looks for routeNotificationFor<ChannelName>().
// Without it, the channel usually falls back to $notifiable->phone.
The key passed to Notification::route() must match whatever name the channel resolves itself under. Mixing 'messagebird' in via() with MessageBirdChannel::class in route() is the most common cause of "the notification reported success but nothing arrived." Pick one convention across the codebase on day one and enforce it in review.
Multi-channel with graceful degradation. For login and payment flows, always keep a second path so a MessageBird outage is not a lockout:
public function via($notifiable): array
{
return array_filter([
$notifiable->phone_e164 ? MessageBirdChannel::class : null,
'mail',
]);
}
Sender-ID rules are country-specific — an alphanumeric originator that delivers to a UK handset can be silently dropped for a US one. Gate on country prefix when SMS is a hard requirement, and default to email until the long code is provisioned.
Per-message originator override. Keep the default in config; override where a country route needs a different sender:
return MessageBirdMessage::create()
->originator(config('messagebird-notifications.originator_uk'))
->content($text);
Queue everything with an explicit retry budget. SMS costs money per attempt; a naive ShouldQueue notification with default retries turns one vendor 500 into a duplicate charge.
class PaymentFailed extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = [10, 60, 300]; // not a flat loop
public int $maxExceptions = 1;
}
Add the idempotency guard at the call site, not in the channel: ShouldBeUnique on the queued notification, or a sent_notifications table keyed on (user_id, event, dedupe_key). The package will happily send the same message twice.
Classify failures yourself with a thin decorator. The channel almost certainly throws one exception type for everything. Wrap it so permanent errors fail fast and transport errors back off:
class SafeMessageBirdChannel
{
public function send($notifiable, $notification)
{
try {
return app(MessageBirdChannel::class)->send($notifiable, $notification);
} catch (\Throwable $e) {
if ($this->isPermanent($e)) { // 400/422: bad number, blocked sender
report($e);
return; // do not retry — do not re-bill
}
throw $e; // 429/5xx/timeout: let the queue back off
}
}
private function isPermanent(\Throwable $e): bool
{
$code = (int) ($e->getCode() ?: 0);
return $code >= 400 && $code < 500 && ! in_array($code, [408, 429], true);
}
}
Return this from via() instead of the vendor channel. It doubles as your provider-swap seam: replacing MessageBird with Twilio later touches one binding, not forty notification classes.
Testing strategy — two layers, because Notification::fake() proves nothing about delivery.
Notification::fake();
$user->notify(new OtpCode('123456'));
Notification::assertSentTo($user, OtpCode::class);
Notification::assertSentOnDemand(OtpCode::class);
That verifies your decision to notify. For the transport layer, note that the MessageBird SDK ships its own Guzzle client — Http::fake() will not intercept it. Use a Guzzle MockHandler injected into the SDK client binding, or bind a fake MessageBirdChannel in the container and assert send() was invoked. Unit-test the message builder (content, originator, recipient) as plain PHP — that is where your real bugs will live.
Health check. Add a scheduled check that credentials resolve and an originator is configured. A missing access key otherwise surfaces as a user-facing 2FA failure in production.
Verify the toMessageBird() return type in the channel source. Some channel implementations expect the message object, others accept a plain string. Returning the wrong shape throws a fatal inside the vendor code, with a stack trace pointing at MessageBird rather than your notification — an easy hour lost.
env() in package source breaks config:cache. Grep before you commit:
grep -rn "env(" vendor/tomatophp/messagebird-notifications/src
Any hits outside the config file mean dev works and production silently passes null. Either publish the config and rewrite it to use config(), or map the values in config/services.php from your own service provider.
The channel-name coupling is the #1 footgun. If via() returns 'messagebird' and the provider never called Channel::extend('messagebird', ...), Laravel throws Driver [messagebird] not supported. If it did register a driver, then Notification::route('messagebird', ...) is the correct on-demand key. Read the service provider once, then standardise — the alternative is chasing notifications that "sent" but never arrived.
Segment math is your cost model. GSM-7 allows 160 characters single, 153 per segment concatenated; Unicode drops to 70/67. One emoji in "Your code is…" flips the whole message to UCS-2 and can double the bill. Keep transactional copy ASCII and under 160, and assert it:
$this->assertLessThanOrEqual(160, strlen((new OtpCode('123456'))->toMessageBird($user)->content));
API acceptance is not delivery. A 200 from MessageBird means queued, not received. Unregistered sender IDs, country restrictions, and DND lists all fail silently. The package does not handle delivery receipts — plan for MessageBird webhooks separately, and design your 2FA UX with a resend path and a timeout rather than a static "sent" state.
E.164 or nothing. 07123456789 will fail or misroute. Normalise at the model boundary with a phone_e164 mutator, not inside the notification. This is the one validation you cannot skip.
Retry storms are invoices. A queued notification that throws retries the whole notification, including channels that already succeeded. Combining mail and SMS in one queued class means a mail failure re-sends the SMS. Keep SMS in its own single-channel notification wherever possible.
Version drift is the real maintenance load. The Bird rebrand moved SDK namespaces and endpoints; a messagebird/php-rest-api major bump is where this package breaks — not in its own code, which barely changes. Commit the lock file, watch the SDK's releases, and re-run your integration test at every Laravel and SDK upgrade. Pin exactly ("tomatophp/messagebird-notifications": "1.0.0") — with one maintainer and a changelog that is a commit link, ^1.0 buys you nothing but surprise.
Extension points, in order of preference: (1) publish and edit the config; (2) decorate the channel as shown above; (3) extend the message builder for fields it doesn't expose; (4) rebind the channel in your own service provider; (5) fork. Never patch vendor/ — with zero dependents and a single maintainer, you are the upstream.
Assume you own this adapter. No SLA, no issue history, no third-party validation. Name an owner, keep the surface behind your own interface or feature flag, and make sure a provider swap touches one binding instead of every notification class. At this package's size, the honest framing is: you are buying a day of "first send works," not a maintained dependency — budget accordingly.
How can I help you explore Laravel packages today?