archer/clickatell-bundle
Symfony bundle that integrates the Clickatell SMS gateway, providing configuration and a service for sending text messages through Clickatell’s API. Useful for adding SMS notifications to Symfony applications.
First, correct the mental model. This is a Symfony bundle, not a Laravel package. There is no extra.laravel block, no service provider, no facade. composer require archer/clickatell-bundle into a modern Laravel app will either fail to resolve (old symfony/framework-bundle constraint, likely <2.3) or install an unused vendor/ directory. Treat day one as a source-reading spike, not an install.
Install into a scratch project (never the production app) so you can read the source without fighting platform constraints:
composer create-project symfony/skeleton /tmp/spike
cd /tmp/spike
composer require archer/clickatell-bundle --ignore-platform-reqs
If resolution fails entirely, git clone https://github.com/archer-soft/ClickatellBundle /tmp/spike gives you the same files.
Read these files first, in this order — they contain 100% of the durable IP:
composer.json — confirms target Symfony/PHP versions and whether the constraint is pinned or dev-master.DependencyInjection/Configuration.php and …Extension.php — the config keys (api_id, user, password, endpoint).Resources/config/services.xml — the service ID you'd inject if it were usable.Decide the path before writing code:
Http client against Clickatell's current REST API. The bundle's transport targets the legacy api.clickatell.com/http/sendmsg endpoint that Clickatell has retired, so the code is reference-only.AppKernel::registerBundles(), then configure:
# app/config/config.yml
archer_clickatell:
api_id: "%clickatell_api_id%"
user: "%clickatell_user%"
password: "%clickatell_password%"
and inject the service into a controller.First real use case: a single outbound transactional SMS (OTP or alert). That is the only capability evidenced by the bundle. Do not assume delivery receipts, inbound, templating, or scheduling exist — they don't.
If you're on Laravel and just need an SMS channel, skip the bundle entirely and start with:
// config/services.php
'clickatell' => [
'key' => env('CLICKATELL_API_KEY'),
],
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.clickatell.key'))
->acceptJson()
->post('https://platform.clickatell.com/messages', [
'to' => ['27820000000'],
'content' => 'Order 1234 shipped',
]);
1. Legacy Symfony: service injection + defensive body parsing. This is the pattern the bundle was built for, and the pattern most likely to bite you. The legacy Clickatell API returns HTTP 200 with ID: <msgid> on success and ERR: <code>, <text> on failure:
public function alertAction(Request $request, ClickatellService $sms)
{
$result = trim($sms->send('+27820000000', 'Order 1234 shipped'));
if (str_starts_with($result, 'ERR:') || !str_starts_with($result, 'ID:')) {
$this->get('logger')->error('clickatell.reject', ['body' => $result]);
throw new \RuntimeException('SMS rejected by Clickatell');
}
return new Response('queued');
}
2. Laravel: port the client, don't install the bundle. A thin owned client is the correct adaptation:
namespace App\Services\Sms;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class ClickatellClient
{
public function __construct(
private readonly string $apiKey,
private readonly string $baseUrl = 'https://platform.clickatell.com',
) {}
public function send(string $to, string $body, ?string $from = null): string
{
$response = Http::withToken($this->apiKey)
->acceptJson()
->asJson()
->timeout(10)
->post("{$this->baseUrl}/messages", array_filter([
'to' => [$this->normalize($to)],
'content' => $body,
'from' => $from,
]));
if ($response->failed() || $response->json('messages.0.error')) {
throw new RuntimeException(
'Clickatell send failed: '.$response->status().' '.$response->body()
);
}
return (string) $response->json('messages.0.apiMessageId');
}
private function normalize(string $to): string
{
return preg_replace('/[^\d+]/', '', $to);
}
}
3. Provider abstraction — the highest-value pattern. The bundle is a liability only because it's a hard dependency. Isolate it behind an interface and the vendor becomes swappable:
interface SmsSender
{
public function send(string $to, string $body): string; // returns provider message id
}
Bind in a service provider using a config flag:
$this->app->singleton(SmsSender::class, fn ($app) => match (config('sms.driver')) {
'clickatell' => new ClickatellClient(config('services.clickatell.key')),
'twilio' => new TwilioClient(config('services.twilio')),
default => throw new \InvalidArgumentException('Unknown SMS driver'),
});
4. Notification channel (Laravel-native ergonomics). Once SmsSender is bound, wire it into Laravel Notifications so feature code never touches the transport:
class ClickatellChannel
{
public function __construct(private SmsSender $sms) {}
public function send($notifiable, $notification): void
{
$message = $notification->toSms($notifiable);
$this->sms->send($notifiable->routeNotificationFor('sms'), $message->body);
}
}
Call sites become $user->notify(new OrderShipped($order)) — clean, queueable, testable.
5. Queue + retry + idempotency. The bundle is synchronous and has no retry logic. In Laravel, wrap sends in a job with bounded retries and an idempotency key so a retry doesn't double-charge:
class SendSmsJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [5, 30, 120];
public function __construct(
public string $to,
public string $body,
public string $idempotencyKey,
) {}
public function handle(SmsSender $sender): void
{
if (cache()->has("sms:sent:{$this->idempotencyKey}")) {
return;
}
$sender->send($this->to, $this->body);
cache()->put("sms:sent:{$this->idempotencyKey}", true, now()->addDay());
}
public function failed(\Throwable $e): void
{
// alert ops — otherwise drops are invisible
}
}
6. Testing. In Laravel, Http::fake() gives you full coverage of the ported client without the bundle:
Http::fake([
'platform.clickatell.com/*' => Http::response([
'messages' => [['apiMessageId' => 'abc', 'accepted' => true]],
]),
]);
$this->assertSame('abc', app(SmsSender::class)->send('+27820000000', 'hi'));
For legacy Symfony tests, stub the service ID behind the same SmsSender interface — that's how you get coverage of the intent without depending on the bundle.
7. Integration order when you actually ship. Vendor account + sender-ID registration first (long lead), then adapter, then channel, then queue, then observability. Code is the short pole.
ERR: <code>, <text> with a 200 status. Bundles of this vintage often check only the transport code. This is the single highest-value five-minute code read and the dominant production failure mode. Always parse the body.?user=…&password=…&api_id=…, which lands in proxy logs, APM traces, and error output. Never carry this pattern forward in a Laravel rebuild — use Authorization headers and env/secret manager. If an existing Symfony app does this, treat it as an incident, not a config.--ignore-platform-reqs in a scratch directory only. Never in the production composer.json — an unpinned dev-master bundle is unreproducible.config/clickatell.php, no facade. composer require will not "just work". Don't burn a day fighting the resolver.sent_sms table.+2782…); normalize at the adapter boundary; reject non-parseable input loudly rather than letting the vendor drop it silently.$tries and a failed() handler. Silent queue loss is a real failure class.symfony/clickatell-notifier. If you were going to fork this bundle for a modern Symfony app, don't — the Notifier bridge is framework-versioned and maintained. Same rationale for Laravel: write the 150-line client.LICENSE in the repo. If it's absent or ambiguous, that's a hard stop for commercial use even as a reference implementation.How can I help you explore Laravel packages today?