laravel-notification-channels/46elks
Send SMS notifications from your Laravel app with 46elks, the Swedish messaging provider. This channel plugs 46elks into Laravel's notification system, letting you deliver text messages to users through a familiar notification class backed by the 46elks API.
Install with an explicit constraint.
composer require laravel-notification-channels/46elks:^1.10.12
Pin it. 1.10.12 is the release that widened the framework constraint to Laravel 12; if you're on Laravel ≤11 and don't need that, 1.10.11 is the safer floor. Avoid * and dev-main — this package has shipped framework-compatibility changes as patch releases, so a floating constraint can silently change what your app resolves against.
Read the four files instead of the README. The package is small: a service provider, a config block, FortySixElksChannel, and FortySixElksMessage. Open the channel and message classes first. The channel is a single Http POST to https://api.46elks.com/a1/sms using basic auth; the message is a fluent payload builder (to(), from(), message()). Ten minutes of reading tells you everything the docs leave implicit: which config keys are read, whether from is required, and what happens on a non-2xx response.
Wire credentials before writing any notification code.
// config/services.php
'46elks' => [
'username' => env('FORTY_SIX_ELKS_USERNAME'), // API user (u12345), not your dashboard login
'password' => env('FORTY_SIX_ELKS_PASSWORD'),
'from' => env('FORTY_SIX_ELKS_FROM'), // approved alphanumeric sender or rented number
],
If the package ships a publishable config, php artisan vendor:publish --provider="NotificationChannels\FortySixElks\FortySixElksServiceProvider" — but verify against src/ which location the channel actually reads. The API user is created separately in the 46elks dashboard and the sender ID needs approval; start that process first because it's the real critical path and it isn't instant.
Add a route and a channel.
// On the notifiable
public function routeNotificationFor46elks($notification): ?string
{
return $this->phone_e164; // +46701234567
}
// The notification
public function via($notifiable): array
{
return [FortySixElksChannel::class];
}
public function to46elks($notifiable): FortySixElksMessage
{
return FortySixElksMessage::create()
->to($notifiable->phone_e164)
->from(config('services.46elks.from'))
->message("Order #{$this->order->id} is on its way.");
}
First use case: an ops alert to your own handset, from php artisan tinker. Send a hardcoded message to yourself before touching anything user-facing. It validates credentials, sender approval, E.164 formatting, and actual delivery in one shot, with no queue or UX surface to muddy the signal. If that doesn't arrive, you have exactly one layer to debug.
On-demand notifications for the pre-model case. Signup OTPs are the canonical example — the user doesn't exist yet, so there's nothing to notify:
Notification::route('46elks', $phone)->notify(new VerifyPhoneNumber($code));
The route key matches the routeNotificationFor46elks method name, so it's 46elks. Wrap this in a small service (PhoneVerification::send($phone)) so the channel name appears in exactly one place in your codebase and a provider swap is a one-file edit.
Queue everything, but choose retry semantics per notification. SMS is not idempotent — a retry is a second billable, user-visible message on someone's handset. That means $tries is a product decision, not a default:
class OtpCode extends Notification implements ShouldQueue, ShouldBeUnique
{
public int $tries = 1; // never double-send an OTP
public int $uniqueFor = 120;
public function uniqueId(): string { return 'otp:'.$this->userId; }
}
class OrderShipped extends Notification implements ShouldQueue
{
public int $tries = 3; // a duplicate status ping is annoying, not harmful
public function backoff(): array { return [5, 15, 60]; }
}
Decide per notification class. A global retry config is the wrong abstraction here.
Keep to46elks() a pure mapper. No queries, no HTTP, no branching on app state beyond the notification's own constructor data. This method runs inside a queue worker; anything slow there becomes queue latency and eventually a timeout that looks like an SMS failure. Resolve the order, the user, the body string in the constructor or in via(). Pass the constructed data in.
Sender strategy: config default, override only when justified. For most apps the sender is fixed and lives in config('services.46elks.from'). If you need per-brand or per-market senders for multi-tenant, keep a config map keyed by tenant and resolve it in one place — never let sender strings get hardcoded inside notification classes, because an approved sender in Sweden may be dropped or rewritten in another market, and you'll be grepping for the fix.
Conditional channel selection for opt-in and quiet hours. Put the preference logic in via() where it's one readable expression rather than scattered conditionals:
public function via($notifiable): array
{
return array_filter([
'mail',
'database',
$notifiable->wantsSms() && ! $notifiable->inQuietHours()
? FortySixElksChannel::class
: null,
]);
}
This is also your kill switch — a feature flag that flips FortySixElksChannel::class to null disables SMS without touching a single notification class.
Normalize phone numbers at the input boundary, never in the channel. A HasPhoneNumber trait with an accessor that returns E.164 is the right home. The channel does not defensively reformat; a leading 0 or a 0046 prefix is a delivery failure with a thin error message, and that's the single most common reason "the SMS just never arrives." Write a test asserting the accessor output format — it's cheap and it catches the whole class of bug.
Test at both layers. Notification::fake() proves routing and channel selection. A faked HTTP client proves the payload shape, which is what actually regresses when you bump the package:
Notification::fake();
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class);
Http::fake(['api.46elks.com/*' => Http::response(['id' => 'abc123'], 200)]);
$user->notify(new OrderShipped($order));
Http::assertSent(fn ($req) => $req['to'] === '+46701234567' && $req['from'] === 'MyBrand');
Block real sends outside production. Either Notification::fake() in a local service provider or a feature-flag branch in via(). A staging OTP landing on a real handset costs money and confuses whoever owns that number — and it will eventually be a customer's number because someone copy-pasted a fixture.
Wrap the channel behind a small service if you need more than send. Metrics, provider-swap seams, per-tenant credentials, custom retry logic — put those in your own SmsNotifier around notify() rather than forking the package. The channel is one HTTP call; the wrapper is where your product logic belongs.
Failures are quiet by default. notify() does not throw when the SMS fails — from Laravel's perspective the notification was delivered. Either wrap sends in try/catch and log the channel's exception, or listen for Illuminate\Notifications\Events\NotificationFailed, which fires when a queued notification exhausts its attempts. For OTPs specifically, provide a "didn't get the code?" path in the UI. Otherwise every credential, balance, or sender-approval issue surfaces as a support ticket instead of an alert.
Semver is a weak signal here. v1.10.12 widened the framework constraint to Laravel 12 as a patch release. That establishes the precedent: a minor bump can change what your app resolves against. Pin exactly, and review constraint diffs in dependency PRs by hand rather than trusting the changelog summary.
config:cache in containerized deploys is a classic trap. If the cache is built at image build time and env vars are injected at runtime, services.46elks.* resolves to null and every send fails auth. Run php artisan config:cache at container boot after env is present, and add a startup assertion that the credentials are non-empty — it turns a silent auth failure into a fast boot failure.
Sender IDs require dashboard approval. An unapproved alphanumeric sender produces an API error, not a silent drop — but you'll only see it if you're logging (see above). If you need inbound messages or guaranteed delivery, rent a number. And verify alphanumeric sender behavior per market before promising SMS to a new region; it's not portable.
Message length is both cost and deliverability. 160 GSM-7 characters per segment. Any non-GSM-7 character — emoji, curly quotes, some accented characters — drops the limit to 70 per segment, and you're billed per segment. Keep OTP and status messages under one segment, and sanitize smart quotes out of user-supplied strings before interpolation. A single apostrophe pasted from Word can double your bill.
Everything is E.164. +46701234567. Not 0701234567, not 0046.... Store normalized, validate at input, test the accessor.
No delivery receipts out of the box. The API returns a message id and that's it. If you need to know the handset received it, register a DLR callback URL in the 46elks dashboard and build a route to receive it — allowlist the source and treat the payload as untrusted. Scope that as separate work; it is not included, and stakeholders will assume it is.
Out of credit looks like a code bug. A surprising share of SMS failures are billing failures. Set a low-balance alert on the 46elks account and check the balance before you start debugging your queue worker.
Don't fork — wrap, and keep the exit path known. The documented fallback is roughly twenty lines:
Http::withBasicAuth($user, $pass)->asForm()->post('https://api.46elks.com/a1/sms', [
'from' => $from, 'to' => $to, 'message' => $body,
]);
This package has two stars, zero dependents, and no SLA. That's fine for a thin, replaceable adapter and unacceptable for a revenue-critical path without a written fallback. Keep to46elks() thin enough that swapping the channel is a one-file change, and don't disable provider auto-discovery without registering the service provider manually — the channel will silently fail to resolve and via() will return a class the notifier can't instantiate.
How can I help you explore Laravel packages today?