Since 1998 5000+ Clients 99.9% Uptime TRAI Certified
🟥 DEVELOPER GUIDE · LARAVEL

SMS API Integration in Laravel

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.

📅 Updated 29 Oct 2026⏱️ 13 min read🧪 Laravel 10, 11 or 12 · PHP 8.1+

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.

1. Configuration

Keep the key out of code. Add three variables to .env and map them in config/services.php so config:cache works in production:

.env
AVANCEZONE_SMS_KEY=your_api_key
AVANCEZONE_SMS_URL=https://whitelabled.securesmsc.com/api/v2
AVANCEZONE_SMS_SENDER=AVNZON
PHPconfig/services.php
<?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.

2. The SmsClient service

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.

PHPapp/Services/Sms/SmsClient.php
<?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.

3. Why the retry rule matters

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 happenedDid the gateway get it?Resend?
DNS failure (cURL 6), connection refused (cURL 7), connect timeoutNoYes, immediately
HTTP 429, 502, 503Refused before processingYes, with back-off
Read timeout ("Operation timed out")Maybe, and probably yesNo. Log and check
HTTP 500 or 504MaybeNo. Log and check
HTTP 400, 401, 422Yes, and refusedNo. 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.

4. A custom notification channel

A channel is any class with a send($notifiable, $notification) method. Laravel resolves it from the container, so the client is injected for you.

PHPapp/Notifications/Messages/SmsMessage.php
<?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, ₹
    ) {}
}
PHPapp/Notifications/Channels/SmsChannel.php
<?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
<?php
// app/Models/User.php
use Illuminate\Notifications\Notification;

public function routeNotificationForSms(Notification $notification): ?string
{
    return $this->phone_e164;   // e.g. "919876543210"
}

5. A queued notification with its template

PHPapp/Notifications/OrderShipped.php
<?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.

6. OTP login with rate limits

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:

PHPapp/Http/Controllers/OtpController.php
<?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.

7. Testing with Http::fake

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.

PHPtests/Feature/SmsTest.php
<?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.

8. DLT checklist before going live

  • Entity approved and its entity ID linked to your gateway account.
  • Header approved, and set as AVANCEZONE_SMS_SENDER.
  • One approved template per notification type, with its ID in toSms(). See DLT template registration.
  • Every link domain and callback number whitelisted.
  • Marketing notifications use route: 'promotional' and are scheduled between 9 AM and 9 PM.
  • Tamil or Hindi messages set unicode: true. They are billed per 70 characters, or 67 per part when longer.

9. Troubleshooting

SymptomCauseFix
SmsRejectedException: Invalid API keyKey missing after config:cache, or wrongRead it via config(), never env() outside config files; re-cache
SmsRejectedException: TEMPLATE_MISMATCHText differs from the approved templateCompare character by character; see DLT error codes
Nothing sent, no errorQueue worker not running, or routeNotificationForSms returned nullRun php artisan queue:work; check the phone column
Same SMS twiceBlanket retry() without a when callback, or the channel rethrows uncertain errorsUse safeToResend() as above
Tamil arrives as ????unicode not setReturn unicode: true from toSms()
Job fails repeatedly with HTTP 503Gateway maintenance or rate limitBackoff 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.

FAQ

Laravel SMS Integration: FAQ

Do I need a package to send SMS from Laravel?
No. Laravel's HTTP client and its notification system already do the hard parts. A small client class and a custom channel, about 120 lines together, give you queued SMS notifications with DLT template IDs and without a third-party dependency to keep updated.
Why not just call Http::retry(3) on every SMS send?
Because Laravel reports a read timeout as a ConnectionException, the same type as a refused connection. A blind retry after a timeout can send the customer the same SMS twice. Retry a send only when the request provably never reached the gateway, or when the gateway answered 429, 502 or 503.
Should SMS notifications be queued?
Yes. Implement ShouldQueue on the notification so a slow gateway never holds up a web request. Set $tries and backoff() on the notification, and let the channel rethrow only errors that are safe to retry.
Where does the DLT template ID go?
Pass it as dlt_template_id in the API call. In the pattern on this page it is a property of the SmsMessage object that each notification's toSms() method returns, so every message type carries its own approved template ID.
How do I test SMS sending in Laravel?
Use Http::fake() to return a canned gateway response, send the notification with notifyNow(), and check the outgoing request with Http::assertSent(). Fake a 504 to prove the client does not resend.
How do I rate-limit OTP SMS in Laravel?
Use the RateLimiter facade with a key per phone number, for example five sends an hour, and the throttle middleware on the route for a per-IP limit. Store only a hash of the OTP in the cache with a 10-minute expiry and cap verification attempts.
SHIP IT THIS WEEK

Get an API Key and an Approved Template

We help with DLT registration and template approval, so your Laravel notifications have something approved to send from day one.