laravel-notification-channels/clickatell
Send SMS notifications through Clickatell with this Laravel notification channel. Install via Composer, configure your API token, and deliver messages using a simple toClickatell method.
1. Install, but read the resolver output.
composer require laravel-notification-channels/clickatell
The last tag is 0.1.5 (Sept 2021, Laravel 8-era) and it has zero dependents. Before you write a single line of application code, confirm it actually installs against your lockfile:
composer why-not laravel-notification-channels/clickatell
If you're on Laravel 10+, don't fight it with --ignore-platform-reqs in production. The driver is a thin wrapper — vendoring it or reimplementing the ~50 lines is the sane path.
2. Publish the config and treat it as authoritative.
php artisan vendor:publish --provider="NotificationChannels\Clickatell\ClickatellServiceProvider" --tag="config"
Open config/clickatell.php and read the key names it actually consumes. Don't copy env variable names from blog posts; wire .env to whatever the published file reads:
CLICKATELL_API_TOKEN=your-token
CLICKATELL_FROM=MyBrand
3. Where to look first. The package is small enough to read end-to-end in ten minutes, and you should — there is no meaningful external documentation:
src/ClickatellChannel.php — the send() method. This tells you how it resolves the recipient, what HTTP client it uses, and what it inspects in the response. Everything in Gotchas depends on this file.src/ClickatellMessage.php — the fluent builder (create(), from(), to(), content()). This is your entire authoring surface.config/clickatell.php — the only injection point the package exposes.4. Teach your notifiable how to be reached.
public function routeNotificationForClickatell(): ?string
{
return $this->phone; // store E.164: +27820000000
}
5. Write the notification. Standard Laravel; only toClickatell() is package-specific.
use Illuminate\Notifications\Notification;
use NotificationChannels\Clickatell\ClickatellChannel;
use NotificationChannels\Clickatell\ClickatellMessage;
class OrderShipped extends Notification
{
public function __construct(public Order $order) {}
public function via(object $notifiable): array
{
return [ClickatellChannel::class];
}
public function toClickatell(object $notifiable): ClickatellMessage
{
return ClickatellMessage::create(
"Order #{$this->order->id} has shipped."
);
}
}
6. Smoke test without touching a model. Prove credentials and sender ID before building anything else:
Notification::route('clickatell', '+27820000000')
->notify(new OrderShipped($order));
Pick exactly one production flow to start: OTP/2FA or password reset. High value per message, tiny blast radius, and failure is loud rather than a silent marketing miss.
Gate the channel in via(). via() runs before the channel does, so it's your cheap guard rail — skip SMS for users with no number or no consent instead of catching an exception downstream:
public function via(object $notifiable): array
{
return $notifiable->phone && $notifiable->sms_opt_in
? [ClickatellChannel::class]
: ['mail'];
}
Use shouldSend() for consent and quiet hours. It's evaluated before dispatch and composes cleanly with queueing:
public function shouldSend(object $notifiable, string $channel): bool
{
return ! $notifiable->inQuietHours();
}
Queue every SMS, always. The channel rides Laravel's notification pipeline, so retries are free — but only if you opt in. Add a tries count, a backoff, and a dedicated queue so a stuck SMS worker can't starve mail:
class OrderShipped extends Notification implements ShouldQueue
{
public int $tries = 3;
public function backoff(): array { return [10, 60, 300]; }
public function viaQueues(): array { return [ClickatellChannel::class => 'sms']; }
}
For OTPs specifically, pair this with ->middleware(new RateLimited('sms')) so a retry storm doesn't become a Clickatell bill.
Abstract the body into a base class. Every project ends up with seven near-identical notifications. One abstract parent holds the scaffolding; each concrete class implements only smsBody():
abstract class SmsNotification extends Notification implements ShouldQueue
{
abstract protected function smsBody(object $notifiable): string;
public function via(object $notifiable): array
{
return $notifiable->phone ? [ClickatellChannel::class] : [];
}
public function toClickatell(object $notifiable): ClickatellMessage
{
return ClickatellMessage::create(
Str::limit($this->smsBody($notifiable), 160, '')
);
}
}
Keep the vendor swappable behind your own seam. This is a thin driver that formats one HTTP request. Treat it as an implementation detail:
interface SmsSender { public function send(string $to, string $body): void; }
class ClickatellSmsSender implements SmsSender { /* delegates to notify() */ }
The highest-leverage hour you'll spend on this package. It turns the eventual replacement — or a second vendor for a second region — into a one-class change rather than a refactor.
Override the recipient sparingly. When a message must go somewhere other than the notifiable's own number (on-call rota, ops escalation), set it on the message:
return ClickatellMessage::create($body)->to('+27820000000');
Prefer Notification::route() for genuinely ad-hoc recipients; it keeps the notification class honest.
Test at both layers. Notification::fake() proves your call sites selected the right channel — it does not exercise the driver:
Notification::fake();
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class, function ($n, $channels) {
return in_array(ClickatellChannel::class, $channels);
});
For the driver layer, Http::fake() and assert the outgoing request body — that's the only way to catch phone-format and sender-ID regressions:
Http::fake(['*' => Http::response(['messages' => [['accepted' => true]]], 202)]);
Log outbound sends. Listen for NotificationSent / NotificationFailed and persist recipient, channel, body hash, and vendor response ID. When someone says "I never got the code," this table is the difference between a five-minute answer and a two-hour investigation.
A 2xx is not proof of delivery. Clickatell's messaging API returns 202 Accepted with a per-message accepted flag — a batch can come back 202 while every message inside was rejected (unapproved sender ID, blocked route, invalid destination). Check what send() actually inspects; if it only asserts the HTTP status, rejected sends will look like successes in your logs. Validate the response body yourself.
There are no delivery receipts here. The package is outbound-only — no status webhooks, no callback parsing. "Did the OTP arrive?" is unanswered by this dependency. If you need fallback logic (SMS → voice → email), budget for building it against Clickatell's status API separately. Decide this before SMS lands on the auth path.
Compatibility is yours to prove, not the release notes'. Pre-1.0, single maintainer, released for Laravel 8, zero dependents — there's no community-tested matrix to lean on. Verify against your actual lockfile and pin the exact version. Never --ignore-platform-reqs in production to force the install.
Config caching can silently blank the credential. If the service provider reads env() directly instead of through config(), php artisan config:cache in production will hand the client a null token. Test on staging with the config cached:
php artisan config:cache && php artisan config:clear
Normalise phone numbers before they reach the channel. The channel passes your string straight through. Enforce E.164 (+, country code, digits — no spaces or dashes) in a model cast and reject at write time, not at send time where the failure lands in a queue and disappears.
Sender ID registration is per-country and outside this package. Alphanumeric sender IDs are fine in some markets and rejected or overwritten in others (notably the US). A failing send that looks like a code bug is often a provisioning issue in your Clickatell account. Confirm from is approved for every destination country in your user base before launch.
Encoding doubles your bill silently. GSM-7 gives 160 characters per segment; a single emoji or curly quote forces UCS-2 at 70. Clickatell bills per segment. Keep bodies ASCII, strip smart punctuation, prefer a short link over a long URL, and sanitise user-supplied names before interpolating them into templates.
A missing route throws — it doesn't no-op. If routeNotificationForClickatell() returns null and the message has no explicit to(), expect an exception inside a queued job. Gate in via()/shouldSend(), and make the phone column non-nullable for users who opted into SMS.
Consent, STOP handling, and suppression lists are yours. The package sends to whoever you route to it. Inbound STOP keywords need a Clickatell webhook plus a suppression table your via() reads. That's a launch prerequisite, not a follow-up ticket — it's the one item here with regulatory consequences.
Throughput is a worker-and-plan question, not a library question. Concurrency is set by your queue workers and your Clickatell plan's rate limit. Simulate a realistic burst (broadcast to 5k users) in staging to surface 429s before production does.
Give the dependency an owner and a review date. Ten stars, one maintainer, last release 2021. Pin it, log it, keep the SmsSender seam — so the eventual swap is a scheduled afternoon instead of an incident.
How can I help you explore Laravel packages today?