laravel-notification-channels/smspoh
Laravel notification channel for SMSPoh that lets you send SMS messages through the SMSPoh gateway using Laravel's native notification system. Configure your API credentials and sender, then deliver SMS notifications via a dedicated channel.
Read the upgrade path before you install
v2.0.0 is a major release that moves the package onto SMSPoh API v3. If you already ship v1, read UPGRADE.md first — the config location, the credential shape, and the sender method all changed. If you're starting fresh, the steps below are the current (v2) shape.
from is still the most common cause of a "successful" install that never delivers.illuminate/support range still covers the Laravel version you're pinned to before bumping the major.Install and configure
composer require laravel-notification-channels/smspoh
The provider is auto-discovered. In v2 the config block lives in config/services.php, not a published config/smspoh.php:
// config/services.php
'smspoh' => [
'key' => env('SMSPOH_KEY'),
'secret' => env('SMSPOH_SECRET'),
'from' => env('SMSPOH_FROM'),
],
SMSPOH_KEY=your-api-key
SMSPOH_SECRET=your-api-secret
SMSPOH_FROM=YourApprovedSender
If you publish the package config and it drops files into config/, be careful it doesn't overwrite your services.php — merge by hand instead. And clear cached config after any of this, or you'll authenticate with stale/empty credentials.
Where to look first (the package is small — read it, don't guess)
src/SmspohChannel.php — now hitting the v3 endpoint; check what status codes and body-level errors throw, and how the new payload fields (schedule, encryption, unicode, webhook, reference) are serialized.src/SmspohMessage.php — the fluent builder. v2 adds scheduledAt(), encrypt(), encryptKey(), unicode(), deliveryReceiptUrl(), clientReference(), and renames sender() → from().src/SmspohServiceProvider.php — confirms exactly which config key it reads (services.smspoh) and under what names.UPGRADE.md — short, but the only authoritative list of what breaks.First use case: an OTP notification
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use NotificationChannels\Smspoh\SmspohChannel;
use NotificationChannels\Smspoh\SmspohMessage;
class SendOtp extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public string $code) {}
public function via($notifiable): array
{
return [SmspohChannel::class];
}
public function toSmspoh($notifiable): SmspohMessage
{
return SmspohMessage::create()
->to($notifiable->phone)
->content("Your verification code is {$this->code}. It expires in 5 minutes.")
->unicode(); // required if the copy includes Burmese script
}
}
Dispatch with $user->notify(new SendOtp('482913'));.
Route the phone number on the model, not in every notification
// app/Models/User.php
public function routeNotificationForSmspoh($notification)
{
return $this->phone; // E.164: +959xxxxxxxxx
}
Then toSmspoh() can omit ->to() entirely, and swapping numbers or adding a second contact is a model change, not a notification change. Use explicit ->to() only for one-off messages.
On-demand notifications for non-models (invites, magic links, unverified leads)
Notification::route('smspoh', '+959771234567')
->notify(new SendOtp($code));
If that route key doesn't resolve for your version, the deterministic escape hatch is ->to($phone) on the message, which bypasses notifiable routing. Wrap it in a helper so the whole app uses one code path.
Make it queued and rate-limited by default
SMS is a side effect, not part of the request. Always implements ShouldQueue, and throttle per user:
public function middleware($notifiable)
{
return [(new ThrottleRequests('sms:' . $notifiable->id, 3, 1))];
}
This stops an OTP-resend button, a retry storm, or a webhook replay from draining credits and getting your sender blocked.
Scheduling — new in v2, and the most useful addition
scheduledAt('Y-m-d H:i:s') lets you hand reminders to SMSPoh instead of holding a queue job open for hours or days:
return SmspohMessage::create()
->content('Your appointment is tomorrow at 09:00.')
->scheduledAt($appointment->starts_at->copy()->subDay()->utc()->format('Y-m-d H:i:s'));
This decouples you from delay() on a queue job — which is exactly what you want for appointment reminders, trial-expiry nudges, and payment due dates, since it survives a queue restart and doesn't consume a delayed-job slot.
Encryption for OTP and sensitive alerts — new in v2
return SmspohMessage::create()
->content($text)
->encrypt()
->encryptKey(config('services.smspoh.encrypt_key'));
Treat the encryption key as a real secret: keep it in .env/a secret manager, never in the message class. Before you ship this for OTP, verify the recipient experience end to end (how the message is decrypted/unlocked on the handset) — an encrypted OTP nobody can read is worse than a plaintext one, and it's the kind of thing you only find out from a real device, not from a passing test.
Delivery receipts via webhook — new in v2
deliveryReceiptUrl($url) lets you set a per-message callback, so you finally get delivery confirmation instead of guessing from a 200:
return SmspohMessage::create()
->content($text)
->clientReference($notificationId)
->deliveryReceiptUrl(route('webhooks.smspoh'));
Route that to a public, verified endpoint. Verify the request (signature/shared secret), keep the handler idempotent — providers retry callbacks — acknowledge fast with a 200, and do the DB write in a queued job. Use clientReference() to correlate the callback back to your notification/order row; it's the cleanest join key you'll get.
Use clientReference() as your idempotency handle
Give every send a stable reference (notification UUID, order id, "{$template}:{$user->id}"). Combined with a short Cache::lock(), it's how you stop a queue retry from producing a second billable SMS, and it gives support a searchable key on the provider side. Same idea as v1, but now first-class.
Localize and template centrally
Burmese copy is long; keep it in lang/my/notifications.php and render in toSmspoh(). This keeps the script handling and segment length in one place — and now makes it trivial to decide per template whether ->unicode() is required.
Multi-tenant / per-purpose senders
Override the sender on the message when different brands share an account — note the method rename:
return SmspohMessage::create()
->content($text)
->from($tenant->sms_from ?? config('services.smspoh.from'));
->sender() is deprecated in v2. Sweep your codebase for it now rather than waiting for it to disappear in v3.
Testing — two levels
// Feature tests: never hit the network
Notification::fake();
$user->notify(new SendOtp('123456'));
Notification::assertSentTo($user, SendOtp::class, function ($n, $channels) use ($user) {
return in_array(SmspohChannel::class, $channels)
&& str_contains($n->toSmspoh($user)->content, '123456');
});
// Channel unit tests: Http::fake() the v3 endpoint and assert the payload,
// including the new fields — scheduled_at, encrypted, unicode,
// delivery_receipt_url, client_reference. Those are the ones most likely
// to regress silently when you bump the package.
Observability
Listen to NotificationSent / NotificationFailed, filter channel === 'smspoh', and log the notification class plus notifiable id. Store the provider message id and your clientReference on your own table — with v2's delivery receipts you can now actually close the loop from queued → sent → delivered.
Isolate the dependency
Wrap SmspohChannel::class behind App\Notifications\Channels\SmsChannel or a base notification class. A package that shipped a breaking major with a config-path move and a method rename is exactly the case for this — a future v3 becomes one file's work.
Fallback channel
public function via($notifiable): array
{
return $notifiable->phone ? [SmspohChannel::class] : ['mail'];
}
config/services.php, token split into key + secret, and sender became from. Miss any one and you get a 401 or a rejected sender that looks like a network problem. Diff your services.php against the release notes, not against a blog post.config:cache freezes old keys. After upgrading, run php artisan config:clear and then config:cache again, and confirm config('services.smspoh.key') is populated — never read env('SMSPOH_KEY') from application code, it returns null in production.env() names changed too. If .env still has SMSPOH_TOKEN, the new keys resolve empty and you'll chase a phantom auth error. Rename the env vars in the same commit as the Composer bump.->unicode(). If your templates contain Burmese script and you don't set it, expect garbled text or a provider-side rejection depending on how the gateway sniffs encoding. Set it deliberately per template, and keep in mind UCS-2 segments are ~70 characters versus 160 for GSM-7 — a "short" Burmese notification can bill as two or three segments. Never truncate mid-codepoint.scheduledAt() takes 'Y-m-d H:i:s', which carries no timezone. It's interpreted in whatever zone the provider assumes. Convert explicitly from your stored UTC timestamps (->utc()->format(...)) and test one scheduled send against the real API before trusting reminders at scale — an off-by-hours reminder is a support ticket per user.encryptKey() may make previously queued-but-unsent messages undecryptable. Don't rotate while a backlog of scheduled encrypted messages is in flight, and never log the key or the plaintext alongside the request.clientReference), and return 200 fast. Also confirm the URL is reachable from the internet — a route() pointing at localhost will silently never fire, and you'll conclude receipts are broken.200 with a body-level failure status. Read SmspohChannel in v2 to see whether it throws on logical errors; if it doesn't, check the response yourself and throw, so the queue retries and NotificationFailed fires. Delivery receipts reduce but don't remove this need.09xxxxxxxxx → +959xxxxxxxxx once (model mutator or a libphonenumber cast) and validate before dispatch. A malformed number is a failed send you paid for.Http::fake() tests are safe; dump() in a controller is not.routeNotificationForSmspoh() returns null and the message has no ->to(), the channel throws. Filter in via() rather than catching after the fact.clientReference() gives you a correlation key, not a guarantee. Keep the Cache::lock("sms:{$user->id}:{$template}", 30) guard around OTP and payment alerts.src/ after the bump. Deprecated-but-working methods (sender()) hide the problem until removal. Grep for sender( and SMSPOH_TOKEN and smspoh.php as an upgrade checklist.src/ diff and UPGRADE.md before bumping, and re-run your Http::fake() payload assertions — those will catch a silently changed field name faster than staging will.SmspohMessage to add helpers (e.g. ->template()), and point the endpoint at a sandbox via config. For retries with backoff or provider failover, write a decorator channel — don't patch vendor/.How can I help you explore Laravel packages today?