fagforbundet/notification-api-sms-notifier-bundle
Symfony bundle for sending SMS notifications through the Notification API. Integrates with Symfony Notifier to dispatch text messages from your application, with configurable API credentials and delivery settings.
Reality check first. This is a Symfony bundle, not a Laravel package. There is no composer require-and-go path for a Laravel app. Before you invest an hour, decide which of these you're doing:
symfony/notifier + this bundle into Laravel (rarely worth it; see Gotchas).SmsSender port (recommended).Where to look first (in order):
composer.json — PHP version, symfony/notifier, symfony/http-client constraints. Run this against your app: composer require fagforbundet/notification-api-sms-notifier-bundle --dry-run and watch resolution.src/ — you need three things: the TransportInterface implementation (does the actual HTTP POST and returns SentMessage/FailedMessage), the transport factory (DSN parsing), and the DependencyInjection/*Extension (registers services).README — look for the DSN format (notification-api+sms://...?) and which config keys the extension reads. That DSN shape is the real "API" you'd be depending on.Services/*.yaml — how the transport gets wired.First use case — pick the smallest possible one. Don't start with OTP or member-broadcast. Start with an internal, low-stakes, single-recipient send (a dev "hello world" to your own phone) to validate:
In a Laravel app, the fastest path is a throwaway Artisan command:
// app/Console/Commands/SmsSmokeTest.php
public function handle()
{
$sender = app(\App\Support\Sms\SmsSender::class);
$result = $sender->send('+4712345678', 'smoke test');
$this->line(json_encode($result)); // id, status, raw response
}
Wire SmsSender to a class that directly instantiates (or forks) the transport — not the bundle's DI container. You want to see one real request/response before touching notification channels.
Pattern 1 — Wrap, don't wire (recommended default). Define an internal contract, then hide this package behind it. This is the single most valuable pattern here:
interface SmsSender {
public function send(string $to, string $body, array $opts = []): SmsResult;
}
final class SmsResult {
public function __construct(
public readonly bool $ok,
public readonly ?string $messageId,
public readonly ?string $status,
public readonly array $raw,
) {}
}
Implement SmsSender once with the package's transport (forked or required), once with a NullSmsSender/fake for tests. Every app call site — notification channel, service, job — talks to SmsSender only. When the vendor changes or the package rots, you change one class.
Pattern 2 — Laravel notification channel. The package gives you a Symfony transport; Laravel wants a channel. Bridge them:
final class SmsChannel {
public function __construct(private SmsSender $sender) {}
public function send(object $notifiable, Notification $notification): void {
if (!method_exists($notification, 'toSms')) return;
$msg = $notification->toSms($notifiable);
$result = $this->sender->send(
$notifiable->routeNotificationFor('sms'),
$msg->body,
$msg->options ?? [],
);
if (!$result->ok) {
// decide: throw, log, or dead-letter — see Gotchas
throw new SmsDeliveryException($result->raw);
}
}
}
Then register it on Notification::extend('sms', ...) in a service provider and call $user->notify(new OrderReady($order)) as normal. This is the day-to-day surface most developers actually touch — they write toSms() and never see the transport.
Pattern 3 — Fork the transport, keep the shape.
Copy the transport class into app/Support/Sms/Transport/ behind the SmsSender port. Keep its internal structure (one concern: map message → provider payload → HTTP → map response → SmsResult). Record the upstream commit SHA in a README note so you can diff later. This eliminates supply-chain risk while preserving the only thing that's actually hard to get right — the vendor payload mapping.
Workflows that matter day-to-day:
dispatch(new SendSmsJob($to, $body)) on a dedicated queue with tries = 3 and exponential backoff. SMS gateways rate-limit; a queued worker with a RateLimited middleware is where you enforce that.config/services.php and .env. Ignore the bundle's YAML extension entirely if you fork — you don't want two config systems to reconcile.messageId/status on the notification record. Redact phone numbers and message content from logs (PII).Integration tips:
Http::fake(): assert the outgoing request shape (URL, auth header, JSON body) and that a 200 with an id → ok=true, a 4xx → ok=false with the response captured in raw.raw on SmsResult but treat it as opaque diagnostics — dashboards should read ok/status, not reach into vendor JSON.The big one: framework gravity. Adding symfony/notifier + symfony/http-client to a Laravel app means you're now pinning Symfony components that Laravel itself pins. composer update can produce resolution conflicts or force you onto Laravel versions you didn't intend. Always --dry-run first. In practice, forking the ~1–2 classes into app code is less work than keeping the dependency graph healthy long-term.
"Sent" is not "delivered." A thin transport returns SentMessage on a 2xx — that means accepted by the vendor API, not delivered to a handset. If your product needs delivery receipts, opt-outs, or keyword handling, this package likely stops at "accepted." Verify from the vendor's docs, not the package. Don't build a UI that shows "Delivered" off a SentMessage.
Error mapping is probably shallow. Inspect the transport's catch blocks. Expect it to either let exceptions bubble or return FailedMessage with limited detail. Neither maps vendor error codes (invalid number vs. rate-limited vs. insufficient credit) to actionable outcomes. Add your own mapping in SmsSender so SmsResult carries a stable error enum; callers should branch on that, not on raw HTTP status.
DSN parsing is your coupling point. The transport is built from a DSN, and the DSN string encodes host/credentials/from. If you fork, you now own that parsing — write it as a small value object with validation and a clear error on malformed config. Config quirks here bite you at deploy time, not test time. Add a php artisan sms:diag command that prints the parsed config (secrets masked) and does a single dry send.
No releases/CI means no safety net. Check git tag and the Actions tab before you depend on anything. With 0 stars and a single-org origin, assume you own it forever. Budget a named owner and a quarterly "does upstream still exist / did the vendor API change" review. MIT means forking is free — so fork proactively into app/Support/Sms/ and stop treating upstream as a dependency.
Testing pitfalls. Http::fake() in Laravel won't intercept a Symfony HttpClient transport — if you keep the Symfony HTTP client, fake at a different layer (MockHttpClient at the Symfony layer) or, better, test your SmsSender adapter and fake it for everything above. Don't try to fake the vendor API through the Symfony stack from Laravel tests; it's brittle and slow.
Extension points that actually matter. The transport class is the seam. Everything else — the bundle, the extension, the factory — is Symfony wiring you'll likely discard. If you want to add idempotency keys, retries with jitter, or per-country sender IDs, do it around SmsSender (a decorator or the job layer), not by patching the transport. Keep the transport a thin, boring HTTP mapper.
Debugging checklist when a send fails: (1) sms:diag — is the DSN parsed and is from set? (2) curl the vendor endpoint by hand with the same credentials to isolate package vs. vendor. (3) Log the raw response on failure only (PII: scrub the body/number first). (4) Confirm the queue worker has the env vars — a common failure is credentials present in web but missing in the worker container.
Compliance is a config decision, not a code one. GDPR/data-processor review, consent tracking for marketing vs. transactional, and retention rules for phone numbers all sit above this package. Build consent checks into the SmsSender decorator so no call site can accidentally send marketing SMS without a recorded opt-in — the transport doesn't know or care.
How can I help you explore Laravel packages today?