A package-free way to send SMS from Laravel to Indian numbers. It builds a small client on the HTTP client, a custom notification channel and queued notifications that carry their DLT template ID, then adds OTP rate limits and tests. Every example targets the real AvanceZone API.
To send SMS from Laravel, wrap the gateway's REST API in a small client built on the Http facade, then expose it through a custom notification channel. Each notification returns an SmsMessage from toSms(), including its DLT template ID, and is queued with ShouldQueue. The detail most integrations get wrong is retries: Laravel reports a read timeout as a ConnectionException, so a blanket retry(3) can send the same SMS twice. Retry only when the request never arrived, or on 429, 502 and 503.
Keep the key out of code. Add three variables to .env and map them in config/services.php so config:cache works in production:
AVANCEZONE_SMS_KEY=your_api_key
AVANCEZONE_SMS_URL=https://whitelabled.securesmsc.com/api/v2
AVANCEZONE_SMS_SENDER=AVNZON<?php
// config/services.php
return [
// ...
'avancezone_sms' => [
'key' => env('AVANCEZONE_SMS_KEY'),
'url' => env('AVANCEZONE_SMS_URL', 'https://whitelabled.securesmsc.com/api/v2'),
'sender' => env('AVANCEZONE_SMS_SENDER', 'AVNZON'),
],
];The sender is your DLT-approved six-character header. If you haven't registered one yet, start with SMS header registration.
One class owns the HTTP details and turns every outcome into one of three exceptions your application can act on: try again later, the outcome is unknown, or the gateway refused the message.
<?php
namespace App\Services\Sms;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
class SmsTemporaryException extends \RuntimeException {} // not sent; safe to retry later
class SmsUncertainException extends \RuntimeException {} // may have been queued; do NOT resend
class SmsRejectedException extends \RuntimeException {} // refused (bad key, sender, template)
class SmsClient
{
/** Send one SMS and return the gateway's message_id. */
public function send(string $to, string $message, array $extra = []): string
{
try {
$response = $this->http()->post('/sms/send', array_merge([
'sender_id' => config('services.avancezone_sms.sender'),
'recipient' => $to,
'message' => $message,
], $extra));
} catch (ConnectionException $e) {
throw self::safeToResend($e)
? new SmsTemporaryException($e->getMessage(), 0, $e)
: new SmsUncertainException($e->getMessage(), 0, $e);
}
if ($response->successful() && $response->json('success')) {
return (string) $response->json('data.message_id');
}
$status = $response->status();
if (in_array($status, [429, 502, 503], true)) {
throw new SmsTemporaryException("Gateway busy (HTTP $status)", $status);
}
if ($status >= 500) {
throw new SmsUncertainException("HTTP $status: the SMS may already be queued", $status);
}
// 401 bad key, 400 sender not approved, 422 validation or TEMPLATE_MISMATCH
$reason = $response->json('error_code') ?? $response->json('message') ?? "HTTP $status";
throw new SmsRejectedException($reason, $status);
}
/** Delivery status: queued | sent | delivered | failed. GETs are safe to retry. */
public function status(string $messageId): ?string
{
return $this->http()
->retry(3, 300, throw: false)
->get('/sms/status/' . rawurlencode($messageId))
->json('data.status');
}
private function http(): PendingRequest
{
return Http::baseUrl(config('services.avancezone_sms.url'))
->withToken(config('services.avancezone_sms.key'))
->acceptJson()
->connectTimeout(5)
->timeout(15)
->retry(3, 250, fn (Throwable $e) => self::safeToResend($e), throw: false);
}
/** True only when the gateway cannot have processed the request. */
public static function safeToResend(Throwable $e): bool
{
if ($e instanceof ConnectionException) {
// cURL 6 = DNS failure, 7 = connection refused, and a *connect-phase*
// timeout reads "Connection timed out after". "Operation timed out"
// means the request was already sent: never resend that.
return (bool) preg_match('/cURL error (6|7):|Connection timed out after/', $e->getMessage());
}
if ($e instanceof RequestException) {
return in_array($e->response->status(), [429, 502, 503], true);
}
return false;
}
}In a real project, put each exception class in its own file so Composer's PSR-4 autoloader can find it. They share a listing here to keep the guide short. Register SmsClient as a singleton in a service provider if you like; the container will also resolve it without registration.
Laravel's retry() takes a when callback, and most tutorials leave it out. Without it, every failure is retried: a refused connection, which is safe, and also a read timeout, which is not. In both cases Laravel throws a ConnectionException, but they mean different things:
| What happened | Did the gateway get it? | Resend? |
|---|---|---|
| DNS failure (cURL 6), connection refused (cURL 7), connect timeout | No | Yes, immediately |
| HTTP 429, 502, 503 | Refused before processing | Yes, with back-off |
| Read timeout ("Operation timed out") | Maybe, and probably yes | No. Log and check |
| HTTP 500 or 504 | Maybe | No. Log and check |
| HTTP 400, 401, 422 | Yes, and refused | No. Fix the cause |
Note throw: false in retry(): without it, Laravel throws a RequestException after the last attempt, and the client would never reach its own status handling.
A channel is any class with a send($notifiable, $notification) method. Laravel resolves it from the container, so the client is injected for you.
<?php
namespace App\Notifications\Messages;
class SmsMessage
{
public function __construct(
public string $content,
public ?string $templateId = null, // DLT template ID from your portal
public string $route = 'transactional', // or 'promotional' (9 AM-9 PM, non-DND only)
public bool $unicode = false, // true for Tamil, Hindi, emoji, ₹
) {}
}<?php
namespace App\Notifications\Channels;
use App\Services\Sms\SmsClient;
use App\Services\Sms\SmsUncertainException;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
class SmsChannel
{
public function __construct(private SmsClient $client) {}
public function send(object $notifiable, Notification $notification): void
{
$to = $notifiable->routeNotificationFor('sms', $notification);
if (! $to) {
return;
}
$sms = $notification->toSms($notifiable);
try {
$id = $this->client->send($to, $sms->content, array_filter([
'dlt_template_id' => $sms->templateId,
'route' => $sms->route,
'unicode' => $sms->unicode ?: null,
]));
Log::info('SMS queued', ['to' => $to, 'message_id' => $id]);
} catch (SmsUncertainException $e) {
// Swallow on purpose: rethrowing would let the queue send it again.
Log::warning('SMS outcome unknown; not retrying', ['to' => $to, 'error' => $e->getMessage()]);
}
// SmsTemporaryException propagates -> the queue retries with backoff.
// SmsRejectedException propagates -> the job fails; fix sender/template.
}
}Tell Laravel which number to use. Store numbers with the country code (919876543210) so nothing has to guess:
<?php
// app/Models/User.php
use Illuminate\Notifications\Notification;
public function routeNotificationForSms(Notification $notification): ?string
{
return $this->phone_e164; // e.g. "919876543210"
}<?php
namespace App\Notifications;
use App\Notifications\Channels\SmsChannel;
use App\Notifications\Messages\SmsMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public $tries = 3;
public function __construct(public string $orderNo, public string $trackingUrl) {}
public function backoff(): array
{
return [30, 120, 600]; // seconds between queue retries
}
public function via(object $notifiable): array
{
return [SmsChannel::class, 'mail'];
}
public function toSms(object $notifiable): SmsMessage
{
// Approved template: Your order {#alphanumeric#} has been shipped. Track it here: {#url#} -AVNZON
return new SmsMessage(
content: "Your order {$this->orderNo} has been shipped. Track it here: {$this->trackingUrl} -AVNZON",
templateId: '1107161234567890123',
);
}
}
// Anywhere in your app:
// $user->notify(new OrderShipped('A1023', 'https://avz.in/t/9KX2'));The text must match the approved template exactly, with only the tagged variables changing. The tracking domain must be whitelisted on DLT; non-whitelisted links have been blocked since 1 October 2024. Variables are now tagged by type ({#alphanumeric#}, {#url#} on Airtel; {#alp#}, {#urg#} on Vi and BSNL), and typed values are generally limited to 40 characters, or 120 for URLs. See DLT variable tagging. Remove 'mail' from via() if you don't also want an email.
OTP endpoints attract abuse, including SMS-pumping fraud that runs up your bill. Limit per phone number in code and per IP on the route, and never store the plain code:
<?php
namespace App\Http\Controllers;
use App\Services\Sms\SmsClient;
use App\Services\Sms\SmsTemporaryException;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
class OtpController extends Controller
{
public function send(Request $request, SmsClient $sms)
{
$data = $request->validate(['phone' => ['required', 'regex:/^[6-9]\d{9}$/']]);
$phone = '91' . $data['phone'];
if (RateLimiter::tooManyAttempts('otp-send:' . $phone, 5)) {
return response()->json(['message' => 'Too many requests. Try again later.'], 429);
}
RateLimiter::hit('otp-send:' . $phone, 3600); // 5 per number per hour
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expires = now()->addMinutes(10);
Cache::put('otp:' . $phone, ['hash' => Hash::make($otp), 'tries' => 0, 'exp' => $expires->timestamp], $expires);
try {
$sms->send($phone, "Your OTP for login is {$otp}. It is valid for 10 minutes. Do not share it with anyone. -AVNZON");
} catch (SmsTemporaryException) {
return response()->json(['message' => 'Could not send the OTP. Please try again.'], 503);
}
return response()->json(['message' => 'OTP sent']);
}
public function verify(Request $request)
{
$data = $request->validate([
'phone' => ['required', 'regex:/^[6-9]\d{9}$/'],
'otp' => ['required', 'digits:6'],
]);
$key = 'otp:91' . $data['phone'];
$row = Cache::get($key);
if (! $row || $row['tries'] >= 5) {
Cache::forget($key);
return response()->json(['message' => 'OTP expired. Request a new one.'], 422);
}
if (! Hash::check($data['otp'], $row['hash'])) {
$row['tries']++;
Cache::put($key, $row, Carbon::createFromTimestamp($row['exp'])); // keep the original expiry
return response()->json(['message' => 'Incorrect OTP.'], 422);
}
Cache::forget($key);
// ...find or create the user and log them in
return response()->json(['message' => 'Verified']);
}
}
// routes/api.php: per-IP limit on top of the per-number one
// Route::post('/otp/send', [OtpController::class, 'send'])->middleware('throttle:20,60');
// Route::post('/otp/verify', [OtpController::class, 'verify'])->middleware('throttle:30,10');The regex accepts Indian mobile numbers (10 digits starting 6 to 9). The OTP send happens in the request rather than a queue because the user is waiting for it. An SMS that is uncertain (SmsUncertainException) still reaches the user most of the time, and the per-number limit stops a confused user from triggering a flood. For login traffic at scale, our OTP SMS service runs on priority routes.
No real SMS should leave a test run. Fake the gateway, send synchronously with notifyNow(), and check what was sent. These are Pest tests; PHPUnit works the same way.
<?php
use App\Models\User;
use App\Notifications\OrderShipped;
use App\Services\Sms\SmsClient;
use App\Services\Sms\SmsUncertainException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('sends the shipped SMS with its DLT template id', function () {
Http::fake([
'*/sms/send' => Http::response([
'success' => true,
'message' => 'SMS queued successfully',
'data' => ['message_id' => 1849302, 'status' => 'queued'],
]),
]);
$user = User::factory()->create(['phone_e164' => '919876543210']);
$user->notifyNow(new OrderShipped('A1023', 'https://avz.in/t/9KX2'), ['App\Notifications\Channels\SmsChannel']);
Http::assertSent(fn (Request $r) =>
str_ends_with($r->url(), '/sms/send')
&& $r['recipient'] === '919876543210'
&& $r['dlt_template_id'] === '1107161234567890123');
});
it('never resends after a 504', function () {
Http::fake(['*/sms/send' => Http::response(['success' => false], 504)]);
expect(fn () => app(SmsClient::class)->send('919876543210', 'Test -AVNZON'))
->toThrow(SmsUncertainException::class);
Http::assertSentCount(1);
});The second test matters most. It proves that a gateway timeout can never turn into a duplicate message, however the code changes later.
AVANCEZONE_SMS_SENDER.toSms(). See DLT template registration.route: 'promotional' and are scheduled between 9 AM and 9 PM.unicode: true. They are billed per 70 characters, or 67 per part when longer.| Symptom | Cause | Fix |
|---|---|---|
SmsRejectedException: Invalid API key | Key missing after config:cache, or wrong | Read it via config(), never env() outside config files; re-cache |
SmsRejectedException: TEMPLATE_MISMATCH | Text differs from the approved template | Compare character by character; see DLT error codes |
| Nothing sent, no error | Queue worker not running, or routeNotificationForSms returned null | Run php artisan queue:work; check the phone column |
| Same SMS twice | Blanket retry() without a when callback, or the channel rethrows uncertain errors | Use safeToResend() as above |
| Tamil arrives as ???? | unicode not set | Return unicode: true from toSms() |
| Job fails repeatedly with HTTP 503 | Gateway maintenance or rate limit | Backoff handles it; raise $tries if needed |
Not on Laravel? The same API is covered for plain PHP, Python, Node.js and Java in our developer guides, and the API itself on the SMS gateway API page.
We help with DLT registration and template approval, so your Laravel notifications have something approved to send from day one.