Envoyer un SMS au Cameroun depuis Laravel
Laravel a déjà tout ce qu'il faut : un client HTTP, des notifications, une file d'attente. Il ne manque qu'un canal qui parle à un opérateur camerounais. Cette page monte ce canal de bout en bout — un service, une notification OTP, un webhook vérifié, et des tests qui n'envoient aucun SMS. Aucun paquet à installer.
Prérequis
- Laravel 10 ou 11, sur PHP 8.1 ou plus récent.
- Une clé API G-SMS commençant par
gsk_, créée dans la console pour une application donnée. - Une file d'attente opérationnelle si vous utilisez la notification
ShouldQueuede cette page — sinon l'appel réseau se fait dans la requête de l'utilisateur. - Un cache partagé (Redis ou base de données) pour dédupliquer les webhooks. Le pilote
arrayne convient pas : il s'oublie à chaque requête.
Configuration
La clé reste dans l'environnement, jamais dans le dépôt. On passe par config/services.php pour que config:cache fonctionne en production.
# .env
GSMS_URL="https://api.sms.gworldship.com"
GSMS_API_KEY="gsk_votre_cle_api"
GSMS_WEBHOOK_SECRET="whsec_votre_secret"config/services.php
<?php
// config/services.php — ajoutez cette entrée au tableau retourné.
return [
// …
'gsms' => [
'url' => env('GSMS_URL', 'https://api.sms.gworldship.com'),
'key' => env('GSMS_API_KEY'),
'webhook_secret' => env('GSMS_WEBHOOK_SECRET'),
],
];Le service
Volontairement sans ->throw() : sur cette API, deux codes d'erreur sont des réponses normales. Le 409 signale que le message existe déjà, le 402 que le crédit est épuisé. Les faire remonter en exception HTTP générique revient à perdre l'information la plus utile.
app/Services/GSms.php
<?php
// app/Services/GSms.php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class GSms
{
private function client(): PendingRequest
{
return Http::baseUrl(config('services.gsms.url'))
->withToken(config('services.gsms.key')) // en-tête Authorization: Bearer gsk_…
->acceptJson()
->timeout(15);
}
/**
* Envoie un SMS et renvoie la réponse telle quelle, sans ->throw().
* C'est volontaire : un 409 n'est pas une panne, et un 402 ne se règle pas
* en réessayant. L'appelant décide.
*/
public function send(string $to, string $text, ?string $clientReference = null): Response
{
$payload = ['to' => $to, 'text' => $text];
if ($clientReference !== null) {
$payload['clientReference'] = $clientReference;
}
return $this->client()->post('/v1/sms', $payload);
}
/** Solde du compte, en francs CFA. */
public function balanceXaf(): int
{
return (int) $this->client()->get('/v1/balance')->json('balanceXaf');
}
}Un canal de notification
Passer par le système de notifications de Laravel plutôt que par un appel direct vous donne la file d'attente, les tentatives, la journalisation et les tests sans rien écrire de plus. Le canal traduit chaque code de réponse en décision : consigner, ignorer, ou relancer.
app/Notifications/Channels/GSmsChannel.php
<?php
// app/Notifications/Channels/GSmsChannel.php
namespace App\Notifications\Channels;
use App\Exceptions\GSmsOutOfCredit;
use App\Services\GSms;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
class GSmsChannel
{
public function __construct(private GSms $gsms)
{
}
public function send(object $notifiable, Notification $notification): void
{
$to = $notifiable->routeNotificationFor('gsms', $notification);
if (! $to) {
return;
}
$message = $notification->toGSms($notifiable);
$response = $this->gsms->send($to, $message['text'], $message['reference'] ?? null);
if ($response->status() === 202) {
Log::info('SMS accepté', [
'messageId' => $response->json('messageId'),
'priceXaf' => $response->json('priceXaf'),
'balanceAfterXaf' => $response->json('balanceAfterXaf'),
]);
return;
}
if ($response->status() === 409) {
// Le corps n'est PAS une erreur : c'est le message déjà envoyé sous
// cette référence. Typiquement, ce job avait déjà tourné.
Log::info('SMS déjà envoyé', ['messageId' => $response->json('messageId')]);
return;
}
if ($response->status() === 402) {
// Rien n'a été débité. Rejouer maintenant ne servirait à rien :
// on remonte une exception métier, pas une panne technique.
throw new GSmsOutOfCredit('Crédit G-SMS épuisé.');
}
// 429 (débit trop rapide ou quota du jour), 503 (opérateur coupé), 5xx :
// ceux-là méritent une nouvelle tentative, donc une exception à relancer.
Log::warning('Échec G-SMS', [
'status' => $response->status(),
'code' => $response->json('code'),
'retryAfter' => $response->header('Retry-After'),
]);
$response->throw();
}
}GSmsOutOfCredit est une exception à vous : une classe vide qui étend RuntimeException suffit. Elle existe pour que le crédit épuisé se distingue d'une panne réseau dans vos journaux et dans votre supervision.
La notification OTP
Le code de connexion est le cas d'usage le plus courant, et c'est celui où l'idempotence compte le plus : si le worker rejoue le job après un incident réseau, l'utilisateur ne doit pas recevoir deux SMS ni être facturé deux fois. La référence contient le code lui-même, donc un rejeu du même job donne la même référence, et l'API répond 409 sans rien débiter.
app/Notifications/SendLoginCode.php
<?php
// app/Notifications/SendLoginCode.php
namespace App\Notifications;
use App\Notifications\Channels\GSmsChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class SendLoginCode extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private string $code)
{
}
public function via(object $notifiable): array
{
return [GSmsChannel::class];
}
/** @return array{text: string, reference: string} */
public function toGSms(object $notifiable): array
{
return [
'text' => "Votre code de connexion est {$this->code}. Il expire dans 5 minutes.",
// Même job rejoué, même référence : l'API renverra 409 au lieu de
// facturer un second SMS. 64 caractères au maximum.
'reference' => 'login-' . $notifiable->getKey() . '-' . $this->code,
];
}
}Le canal a besoin de savoir où écrire. Sur le modèle notifiable :
<?php
// app/Models/User.php — c'est ainsi que le canal trouve le numéro :
// routeNotificationFor('gsms') appelle routeNotificationForGsms().
class User extends Authenticatable
{
use Notifiable;
// …
/** Format attendu : +2376XXXXXXXX, 2376XXXXXXXX ou 6XXXXXXXX. */
public function routeNotificationForGsms(): ?string
{
return $this->phone;
}
}Et à l'usage :
<?php
// Depuis un contrôleur, un service, une commande…
$code = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
cache()->put("login-code:{$user->id}", $code, now()->addMinutes(5));
$user->notify(new SendLoginCode($code));
// La notification implémente ShouldQueue : la requête HTTP de l'utilisateur
// rend la main tout de suite, l'appel à G-SMS part depuis un worker.Les réponses qu'il faut savoir traiter
| Statut | Code | Signification | Que faire |
|---|---|---|---|
| 202 | — | Accepté, débité, mis en file. | Stocker messageId pour rapprocher le webhook. |
| 402 | INSUFFICIENT_BALANCE | Crédit épuisé. Aucun débit. | Ne pas relancer le job en boucle : alerter, recharger, rejouer. |
| 409 | — | Cette clientReference a déjà servi. | Considérer l'envoi comme fait. Le corps porte le message d'origine. |
| 429 | RATE_LIMITED | Plus de 10 requêtes par seconde sur la clé. | Relancer le job après Retry-After (1 s). |
| 429 | QUOTA_EXCEEDED | Quota journalier atteint (désactivé par défaut). | Reporter au lendemain ou demander un relèvement. |
| 503 | OPERATOR_DISABLED | Envois suspendus vers cet opérateur. Aucun débit. | Reprogrammer après Retry-After (une heure). |
{ code, message }, c'est le message déjà accepté, au format du 202 et sans balanceAfterXaf. Un $response->throw() posé sur tous les échecs transforme donc un envoi réussi en exception, et votre job repartira indéfiniment. Recevoir les accusés de remise
Le 202 dit « accepté », pas « reçu ». La suite arrive en push sur une URL que vous déclarez dans la console. Un seul événement existe, sms.sent, émis à chaque changement de statut : SENT, puis DELIVERED ou FAILED, ces deux derniers étant définitifs. Un FAILED est remboursé automatiquement, une seule fois.
<?php
// routes/api.php — hors du groupe web, donc sans jeton CSRF.
use App\Http\Controllers\GSmsWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/gsms', GSmsWebhookController::class);app/Http/Controllers/GSmsWebhookController.php
<?php
// app/Http/Controllers/GSmsWebhookController.php
namespace App\Http\Controllers;
use App\Models\SmsMessage;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
class GSmsWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
// Le corps BRUT : la signature porte sur les octets reçus, pas sur un
// tableau re-sérialisé par Laravel.
$raw = $request->getContent();
$timestamp = (string) $request->header('X-GSms-Timestamp');
$signature = (string) $request->header('X-GSms-Signature');
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return response('stale timestamp', 400);
}
$expected = 'sha256=' . hash_hmac(
'sha256',
$raw . '.' . $timestamp,
config('services.gsms.webhook_secret')
);
// hash_equals : comparaison à temps constant, non négociable.
if (! hash_equals($expected, $signature)) {
return response('bad signature', 401);
}
$event = $request->json();
// Livraison « au moins une fois ». Cache::add ne pose la clé que si elle
// n'existe pas : il faut un cache partagé (Redis, base), pas 'array'.
if (! Cache::add('gsms-event:' . $event->get('eventId'), true, now()->addDays(2))) {
return response()->noContent();
}
$data = $event->get('data');
SmsMessage::where('message_id', $data['messageId'])->update([
'status' => $data['status'], // SENT, DELIVERED ou FAILED
'failure_reason' => $data['failureReason'], // null tant qu'il n'y a pas d'échec
]);
// 204 : tout code 2xx vaut accusé de réception. Répondez en moins de 10 s.
return response()->noContent();
}
}- La signature vaut
HMAC_SHA256(secret, corps_brut + "." + timestamp), en hexadécimal minuscule, préfixée desha256=. - Refusez un horodatage qui s'écarte de plus de cinq minutes : c'est la protection contre le rejeu.
applicationIdcontient le slug de l'application, pas son identifiant technique, malgré son nom.- À la différence des réponses de l'API, le corps du webhook conserve ses champs nuls :
clientReferenceetfailureReasonsont toujours présents, ànullle cas échéant. - Dédupliquez sur
eventId.X-GSms-Deliverychange à chaque tentative et ne convient pas. - En cas d'échec, les tentatives s'espacent : 1 min, 5 min, 30 min, 2 h, 12 h, puis toutes les 12 h, et s'arrêtent après 48 h. La livraison passe alors en
EXHAUSTEDet reste rejouable depuis la console.
Tester sans envoyer un seul SMS
Http::fake() intercepte l'appel : vos tests vérifient le contenu du message et la référence d'idempotence sans rien débiter. Faites-le tôt — c'est la seule façon de vérifier qu'un rejeu de job produit bien la même clientReference.
tests/Feature/SendLoginCodeTest.php — syntaxe Pest ; le corps est identique en PHPUnit
<?php
// tests/Feature/SendLoginCodeTest.php
use App\Models\User;
use App\Notifications\SendLoginCode;
use Illuminate\Support\Facades\Http;
it('envoie le code par SMS', function () {
Http::fake([
'api.sms.gworldship.com/v1/sms' => Http::response([
'messageId' => '8f14e45f-ceea-467a-9c1e-1a4f1c3b0d21',
'to' => '+237690000000',
'operator' => 'ORANGE',
'segments' => 1,
'priceXaf' => 5,
'status' => 'QUEUED',
'clientReference' => 'login-1-4821',
'balanceAfterXaf' => 95,
'createdAt' => '2026-09-12T08:41:07Z',
], 202),
]);
$user = User::factory()->create(['phone' => '+237690000000']);
$user->notify(new SendLoginCode('4821'));
Http::assertSent(fn ($request) => $request['to'] === '+237690000000'
&& str_contains($request['text'], '4821')
&& $request['clientReference'] === 'login-' . $user->id . '-4821');
});Combien coûte l'exemple de cette page
| Texte envoyé | « Votre code de connexion est 4821. Il expire dans 5 minutes. » — 59 caractères |
| Encodage | GSM-7 : tous les caractères sont dans l'alphabet standard |
| Segments | 1 (jusqu'à 160 caractères en GSM-7) |
| Destinataire | +237690000000 — préfixe 69, donc Orange |
| Coût | 5 XAF |
5 XAF également vers MTN, 7 XAF vers Camtel et Nexttel, 10 XAF sur la route par défaut, celle des préfixes absents du plan de numérotation de l'ART. Un échec est remboursé.
Ce qui va vous surprendre
- Une apostrophe peut doubler la facture. Tant que le texte tient dans l'alphabet GSM-7, un segment vaut 160 caractères ; sinon il tombe à 70. Or l'apostrophe typographique « ’ », le « ç » minuscule, le « ê » et les guillemets « » n'appartiennent pas à cet alphabet. Une vue Blade qui applique des quotes typographiques suffit à faire basculer tous vos messages. Au-delà d'un segment, la capacité descend à 153 caractères en GSM-7 et 67 en UCS-2 ; ces tailles sont administrables, donc fiez-vous au
segmentsrenvoyé. - Le destinataire voit un numéro, pas un nom. Il n'existe pas d'expéditeur personnalisé : G-SMS achemine du transactionnel depuis une SIM camerounaise, et c'est ce numéro qui s'affiche. Nommez donc votre service dans le texte.
- Les limites. 10 requêtes par seconde et par clé, 1 600 caractères par message, 64 caractères pour
clientReference. Le quota journalier est désactivé par défaut. - Un message n'appartient qu'à son application.
GET /v1/sms/{messageId}renvoie 404, et non 403, si la clé relève d'une autre application.
Prêt à envoyer ton premier SMS ?
Crée un compte, reçois 100 F de crédit et une clé API immédiate.
Créer un compte