symfony/clickatell-notifier
Symfony Notifier integration for Clickatell SMS. Send text messages through Clickatell's API using a simple DSN: clickatell://ACCESS_TOKEN@default?from=FROM, where ACCESS_TOKEN is your Clickatell auth token and FROM is the sender.
This is not a Laravel package — it is a Symfony Notifier bridge. There is no auto-discovered service provider, no config/notifier.php, and no facade. You get exactly one class that implements Symfony\Component\Notifier\Transport\TransportInterface plus a factory. Everything else is on you.
Install
composer require symfony/notifier symfony/clickatell-notifier symfony/http-client
Look at first: the README DSN line and then Symfony\Component\Notifier\Transport::fromDsn(), Symfony\Component\Notifier\Message\SmsMessage, and Symfony\Component\Notifier\Recipient\SmsRecipient. Those four are the entire surface you'll touch day-to-day.
Wire it once in AppServiceProvider:
// config/services.php
'clickatell' => ['dsn' => env('CLICKATELL_DSN')],
// AppServiceProvider::register()
$this->app->singleton(NotifierInterface::class, function () {
return new Notifier([
Transport::fromDsn(config('services.clickatell.dsn')),
]);
});
CLICKATELL_DSN=clickatell://ACCESS_TOKEN@default?from=MyBrand
First use case — a transactional OTP, sent synchronously from tinker to prove credentials and sender ID work before you build anything around it:
$sent = app(NotifierInterface::class)->send(
new SmsMessage('Your code is 123456', new SmsRecipient('+27821234567'))
);
dump($sent->getMessageId()); // Clickatell message id — persist it
Once that returns a message ID, wrap it in a queued Laravel notification (below) rather than calling it inline.
1. Custom notification channel (the canonical Laravel pattern). Keep Symfony types out of your notification classes by adapting at the channel boundary:
class ClickatellChannel
{
public function __construct(private NotifierInterface $notifier) {}
public function send(object $notifiable, Notification $notification): void
{
$sms = $notification->toClickatell($notifiable);
$message = new SmsMessage(
$sms->content,
new SmsRecipient($notifiable->routeNotificationFor('clickatell', $notification))
);
$sent = $this->notifier->send($message);
// Persist for support tickets, delivery webhooks, audits
$notifiable->smsLogs()->create([
'message_id' => $sent->getMessageId(),
'body' => $sms->content,
]);
}
}
Then via() returns ['clickatell'] and Laravel resolves the channel from the container.
2. Always queue. Put ShouldQueue on the notification and give it $tries/backoff. SMS is a network call to a third party; you do not want Clickatell latency in your request/response cycle. Laravel's queue retry replaces Symfony Messenger's retry — you are not running Messenger, so the retry semantics are whatever you configure in the notification.
3. Domain service wrapper. Bind a thin SmsSender/ClickatellClient service that owns the NotifierInterface and exposes send(string $to, string $body, array $context = []). Every call site depends on your interface, not Symfony's. When Clickatell's pricing or reliability becomes a problem, you swap the implementation and nothing else changes.
4. One transport per sender. The from value lives in the DSN, not in SmsMessage. If you need per-brand/per-tenant sender IDs, build a small factory keyed by tenant that constructs Transport::fromDsn() per DSN, and cache the resulting Notifier instances:
class SmsNotifierFactory
{
private array $notifiers = [];
public function for(string $senderId): NotifierInterface
{
return $this->notifiers[$senderId] ??= new Notifier([
Transport::fromDsn("clickatell://{$this->token}@default?from={$senderId}"),
]);
}
}
5. Idempotency at the domain layer. Laravel queue retries can fire the same notification twice. For OTPs and payment alerts, store a dedupe key (user + template + window) before dispatching and short-circuit inside the channel. Clickatell will happily send duplicates and bill you for both.
6. Testing. Swap the HttpClientInterface for MockHttpClient inside a transport you build in the test, or bind a fake SmsSender in the container and assert on your own interface. The latter is far less brittle than asserting on Clickatell's JSON payload:
$this->mock(SmsSender::class)
->shouldReceive('send')
->once()
->with('+27821234567', Mockery::on(fn ($b) => str_contains($b, '123456')));
7. Local / staging. Use Symfony's null:// DSN via CLICKATELL_DSN=null:// in .env.testing and .env.local. That gives you a real SentMessage with a fake ID and zero credits burned.
default. clickatell://[email protected]?from=X will fail; the factory expects the scheme + default host and reads from from the query string.+ or a local format will get rejected by Clickatell, not by PHP. Validate at the edge with propaganistas/laravel-phone or similar before it reaches the bridge.to is sent as an array. There was a real bug — "send the recipient phone number as an array" — fixed in 7.1.2 / 7.0.9 / 6.4.9. If you pin an older patch and see Clickatell 400s on a perfectly valid number, that's why. Minimum-safe versions: >=6.4.9, >=7.1.2, >=8.x.^7.1. Don't blanket require symfony/clickatell-notifier:^8.0.Symfony\Component\Notifier\Exception\TransportExceptionInterface (and UnsupportedMessageTypeException). Your App\Exceptions\Handler and retry() logic need to know about them explicitly if you care about retrying network errors but not validation errors.MessageEvent / FailedMessageEvent are dispatched on a PSR-14 dispatcher you'd have to build and bridge yourself. Simpler: decorate NotifierInterface with a logging decorator that fires your own Laravel events.MessageOptions. from and auth come from the DSN only. Don't design a per-message override feature on top of this without the factory pattern above.retry_after + a slow Clickatell outage can pile up jobs — set a reasonable $tries (3–5) and backoff([10, 60, 300]).TransportInterface yourself against Clickatell's REST API directly, or keep the bridge and decorate NotifierInterface. There is nothing on the Laravel side to extend — no events, no macros, no config schema.symfony/notifier pull. It suggests/adds a lot of unrelated transports. Run composer why symfony/notifier after install and consider --no-dev trimming; it can bloat containers meaningfully compared to a 40-line Guzzle call.How can I help you explore Laravel packages today?