SMS API in PHP: Full Integration Tutorial (Production Ready, 2026)

A complete PHP SMS API integration tutorial that goes past the copy-paste cURL snippet: credential handling, Guzzle and PSR-18 clients, Laravel queue workers, India DLT template validation, OTP generate and verify, delivery report polling, a webhook receiver, retry logic, idempotency and unit tests, all against real SMSGatewayCenter endpoints.

Featured image for SMS API in PHP: Full Integration Tutorial (Production Ready, 2026)

Table of Contents

  1. Executive Summary
  2. TL;DR
  3. Prerequisites and Account Setup
  4. Architecture: Where the SMS Client Belongs in a PHP Application
  5. Your First Send: Raw PHP cURL
  6. A Better Client: Guzzle and PSR-18
  7. Authentication: userid and password versus apiKey
  8. Sending to Many Recipients Without Melting Your Web Server
  9. India Specific: DLT Entity IDs, Template IDs and Pre-Send Validation
  10. OTP in PHP: Generate and Verify Without Rolling Your Own
  11. Handling Delivery Reports in PHP
  12. Error Handling, Retries and Idempotency
  13. Laravel Integration
  14. Security: Credentials, Input Validation and Log Hygiene
  15. Testing Your Integration Without Sending Real SMS
  16. Performance and Scaling Notes
  17. Production Readiness Checklist
  18. Common Mistakes and How to Fix Them
  19. FAQs

1. Executive Summary

Integrating an SMS API in PHP takes about fifteen lines of cURL. Integrating an SMS API in PHP in a way that survives contact with production takes considerably more: a transport layer that does not block your request thread, credentials that are not sitting in a Git repository, a retry policy that distinguishes a transient network fault from a permanently invalid recipient, an idempotency key that prevents a queue retry from sending a customer the same one-time password twice, a delivery receipt path that is completely separate from the submit path, and, if you send to Indian numbers, a DLT template registration that matches your outbound text character for character.

Diagram of a PHP application sending SMS through a gateway to mobile handsets with a delivery receipt callback returning to the application.

This tutorial covers all of it, in PHP, against real endpoints on the SMSGatewayCenter API. Every URL, HTTP method and parameter name in the code below is taken from the live developer API documentation, not from a generic example. If you want the language-agnostic orientation first, start with the SMS API getting started guide and come back here for the PHP implementation.


2. TL;DR

To send an SMS from PHP, POST to https://unify.smsgateway.center/SMSApi/send with userid and password (or an apiKey HTTP header), plus sendMethod=quick, mobile, msg, senderid, msgType=text and output=json. For Indian traffic, add dltEntityId and dltTemplateId. Parse the JSON response, store the returned transactionId, and treat the HTTP 200 as “accepted for delivery”, not “delivered”. Delivery outcome arrives later, either by polling https://unify.smsgateway.center/SMSApi/reports/status with method=getDlr or by registering a webhook through https://unify.smsgateway.center/SMSApi/webhook/create.

Everything after that first call is the part that matters: move the send into a queue, key it for idempotency, retry only on retryable failures, and validate DLT templates before you submit rather than after you are rejected.


3. Prerequisites and Account Setup

You need four things before the first line of code.

PHP 8.1 or newer. Every sample here runs on 8.1 and above. The typed properties, enums, readonly promotion and named arguments used below are all 8.1 features. On PHP 7.4 the code works with minor syntax edits, but 7.4 has been end of life since 2022 and should not be carrying production traffic.

The cURL extension, or Guzzle. Check with php -m | grep curl. If cURL is unavailable in your hosting environment, install Guzzle 7, which falls back to PHP’s stream wrapper transport when cURL is absent.

An SMSGatewayCenter account with an approved sender ID. Sender IDs in India are alphanumeric and capped at six characters. Create one through the panel or programmatically via POST https://unify.smsgateway.center/SMSApi/senderid/create, documented at Create Sender ID. Approval is not instant, so do this before you start coding, not while you are debugging.

For Indian traffic, a registered DLT entity and at least one approved template. This is not optional and it is not something you can work around. If you have not been through this yet, follow the DLT registration walkthrough first. Section 9 covers the API side.

Install Dependencies

composer require guzzlehttp/guzzle:^7.8
composer require --dev phpunit/phpunit:^10.5

SMSGatewayCenter also publishes an official PHP SDK on Packagist. Per the PHP SDK page, you install it with:

composer require sgcsdk/sgcapi

Then open ../vendor/sgcsdk/sgcapi/src/config/common/sgc_constant.php and change the server host constant from the local default to production:

const SERVER_HOST = "https://unify.smsgateway.center";

The rest of this tutorial deliberately builds against the raw HTTP API rather than the SDK. That is not a criticism of the SDK, it is a practical choice: understanding the wire format means you can debug a failing send by reading a request log, you are not blocked when you need a parameter the SDK version you installed via composer does not expose yet, and the same knowledge transfers directly to your Node.js, Python or Java services. Use the SDK once you understand what it is wrapping.


4. Architecture: Where the SMS Client Belongs in a PHP Application

The most common structural mistake in a PHP SMS integration is calling the gateway inline from a controller during a web request. It looks harmless at first. Then the gateway takes 900 milliseconds to respond during a network hiccup, your PHP-FPM worker pool is held open, and a checkout page that never touched SMS starts timing out because there are no free workers left.

Structure the integration in four layers.

LayerResponsibilityWhat it must not do
Controller / entry pointValidate user input, decide that a message is warranted, hand offNever call the gateway directly, never format a template
Message serviceBuild the payload, resolve sender ID and DLT template, assign an idempotency key, enqueueNever open a socket
Queue workerExecute the HTTP call, interpret the response, apply retry policy, persist the transaction IDNever contain business rules about who gets messaged
Transport clientSerialise parameters, set timeouts, return a typed response object or throw a typed exceptionNever know what a “welcome message” is

The only case where an inline synchronous send is defensible is a one-time password, where the user is staring at a screen waiting for the code and a queue adds latency you cannot justify. Even then, wrap it in a hard timeout and a circuit breaker. Section 10 covers this.

Illustration about Architecture: Where the SMS Client Belongs in a PHP Application

5. Your First Send: Raw PHP cURL

Start with the smallest thing that works, then refactor. The send endpoint is POST https://unify.smsgateway.center/SMSApi/send, form encoded.

<?php
declare(strict_types=1);

$params = [
    'userid'         => getenv('SGC_USERID'),
    'password'       => getenv('SGC_PASSWORD'),
    'sendMethod'     => 'quick',
    'mobile'         => '919999999999',
    'msg'            => 'Your order 4821 has shipped. Track it at https://example.com/t/4821',
    'senderid'       => 'SMSGAT',
    'msgType'        => 'text',
    'duplicatecheck' => 'true',
    'output'         => 'json',
];

$ch = curl_init('https://unify.smsgateway.center/SMSApi/send');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query($params),
    CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
    CURLOPT_CONNECTTIMEOUT => 5,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_1_1,
]);

$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err    = curl_error($ch);
curl_close($ch);

if ($body === false) {
    throw new RuntimeException("Transport failure: {$err}");
}

$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
var_dump($status, $decoded);

A successful response looks like this:

{
    "status": "success",
    "mobile": "919999999999",
    "invalidMobile": "",
    "transactionId": "a1b2c3d4-...",
    "statusCode": "200",
    "reason": "success"
}

Four things in that snippet are doing real work and are routinely omitted from tutorials elsewhere.

CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT are set explicitly. cURL’s default timeout is effectively infinite. Without these two lines a single stalled connection can pin a PHP process indefinitely. Five seconds to connect and fifteen seconds total is a sane starting point for a submit call.

http_build_query handles URL encoding for you. This matters more than it looks. If your password contains a +, & or #, hand-concatenating a query string will silently corrupt it and you will spend an afternoon convinced your credentials are wrong. The same applies to message bodies containing ampersands.

duplicatecheck set to true asks the platform to suppress an identical message to the same recipient. It is a useful second line of defence, but it is not a substitute for application-level idempotency, which section 12 covers, because the duplicate window is a platform behaviour you do not control.

JSON_THROW_ON_ERROR means a malformed response raises an exception rather than quietly returning null and letting $decoded['status'] produce a warning and a false negative.

GET versus POST

The API accepts both. Use POST. A GET request puts your password and the full message body into the URL, which means it lands in web server access logs, proxy logs, and any intermediate CDN log on the path. That is a credential leak and, if the message contains a one-time password or an account number, a data leak as well. The only legitimate use of GET here is a quick manual test from a terminal.

curl -X POST 'https://unify.smsgateway.center/SMSApi/send' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'userid=YourUsername' \
  --data-urlencode 'password=YourPassword' \
  --data-urlencode 'sendMethod=quick' \
  --data-urlencode 'mobile=919999999999' \
  --data-urlencode 'msg=Test message from cURL' \
  --data-urlencode 'senderid=SMSGAT' \
  --data-urlencode 'msgType=text' \
  --data-urlencode 'output=json'

If you would rather explore in a GUI first, the SMS Postman collection has every endpoint pre-populated.


6. A Better Client: Guzzle and PSR-18

Raw cURL is fine for one call. It is a liability once you have five endpoints, because timeout configuration, error mapping and retry logic get copy-pasted and then drift. Wrap it once.

<?php
declare(strict_types=1);

namespace App\Sms;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;

final class SmsGatewayClient
{
    private const BASE_URI = 'https://unify.smsgateway.center/';

    public function __construct(
        private readonly Client $http,
        private readonly SmsCredentials $credentials,
    ) {
    }

    public static function create(SmsCredentials $credentials): self
    {
        $http = new Client([
            'base_uri'                  => self::BASE_URI,
            RequestOptions::CONNECT_TIMEOUT => 5.0,
            RequestOptions::TIMEOUT         => 15.0,
            RequestOptions::HTTP_ERRORS     => false,
            RequestOptions::HEADERS         => [
                'User-Agent' => 'acme-app/1.0 (+https://example.com)',
                'Accept'     => 'application/json',
            ],
        ]);

        return new self($http, $credentials);
    }

    /**
     * @param array<string, scalar> $params
     * @return array<string, mixed>
     */
    public function post(string $path, array $params): array
    {
        $payload = $this->credentials->applyToBody($params);
        $payload['output'] = 'json';

        try {
            $response = $this->http->post($path, [
                RequestOptions::FORM_PARAMS => $payload,
                RequestOptions::HEADERS     => $this->credentials->applyToHeaders([]),
            ]);
        } catch (ConnectException $e) {
            throw new SmsTransportException('Could not reach the SMS gateway.', 0, $e);
        } catch (RequestException $e) {
            throw new SmsTransportException('SMS gateway request failed.', 0, $e);
        }

        $status = $response->getStatusCode();
        $body   = (string) $response->getBody();

        if ($status >= 500) {
            throw new SmsTransportException("Gateway returned HTTP {$status}.");
        }

        try {
            /** @var array<string, mixed> $decoded */
            $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new SmsTransportException('Gateway returned a non-JSON body.', 0, $e);
        }

        return $decoded;
    }
}

Two design decisions worth explaining.

HTTP_ERRORS is set to false. By default Guzzle throws on any 4xx or 5xx. That sounds helpful and is actually harmful here, because a 4xx from this API often carries a JSON body with a specific statusCode and reason that you need in order to decide whether to retry. Throwing before you parse the body discards the diagnosis. Handle the status code yourself.

Transport failures and application failures get different exception types. A ConnectException means the message definitely was not accepted and is safe to retry. A parsed response with status: error means the gateway received and rejected your request, and retrying an invalid sender ID a hundred times will not make it valid. Conflating these two is the single most common cause of retry storms.

The Send Method

public function send(OutboundMessage $message): SendResult
{
    $params = [
        'sendMethod'     => 'quick',
        'mobile'         => $message->msisdn,
        'msg'            => $message->text,
        'senderid'       => $message->senderId,
        'msgType'        => $message->isUnicode ? 'unicode' : 'text',
        'duplicatecheck' => 'true',
    ];

    if ($message->dltEntityId !== null) {
        $params['dltEntityId']   = $message->dltEntityId;
        $params['dltTemplateId'] = $message->dltTemplateId;
    }

    $raw = $this->post('SMSApi/send', $params);

    return SendResult::fromArray($raw);
}
final class SendResult
{
    private function __construct(
        public readonly bool $accepted,
        public readonly ?string $transactionId,
        public readonly string $statusCode,
        public readonly string $reason,
        public readonly string $invalidMobile,
    ) {
    }

    /** @param array<string, mixed> $raw */
    public static function fromArray(array $raw): self
    {
        return new self(
            accepted:      ($raw['status'] ?? '') === 'success',
            transactionId: isset($raw['transactionId']) ? (string) $raw['transactionId'] : null,
            statusCode:    (string) ($raw['statusCode'] ?? '0'),
            reason:        (string) ($raw['reason'] ?? 'unknown'),
            invalidMobile: (string) ($raw['invalidMobile'] ?? ''),
        );
    }
}

Note invalidMobile. When you submit a batch, the gateway can accept some recipients and reject others in the same response. A boolean success flag alone will make you believe every number went through. Always inspect this field and reconcile it against what you submitted.


7. Authentication: userid and password versus apiKey

The API supports two authentication methods and you should pick deliberately.

userid + passwordapiKey HTTP header
Where it travelsIn the request body or query stringIn an HTTP header
Log exposure riskHigh if sent via GET, moderate via POSTLow, headers are rarely logged by default
RotationChanging it changes your panel login tooRotate independently of the account password
Revocation blast radiusRevoking locks you out of the panelRevoke one key, other integrations keep working
Per-integration separationNot possible, one credentialOne key per service is possible
Best forQuick tests, single-script useAnything running in production

Use the API key in production. Create and manage keys through the endpoints listed under Create API Key in the developer API sidebar. A practical pattern is one key per deployed service, so that when your reporting cron leaks a key in a stack trace you revoke that key alone and your checkout flow keeps sending.

<?php
declare(strict_types=1);

namespace App\Sms;

final class SmsCredentials
{
    private function __construct(
        private readonly ?string $apiKey,
        private readonly ?string $userId,
        private readonly ?string $password,
    ) {
    }

    public static function fromApiKey(string $apiKey): self
    {
        return new self($apiKey, null, null);
    }

    public static function fromBasic(string $userId, string $password): self
    {
        return new self(null, $userId, $password);
    }

    /**
     * @param array<string, scalar> $params
     * @return array<string, scalar>
     */
    public function applyToBody(array $params): array
    {
        if ($this->apiKey !== null) {
            return $params;
        }

        $params['userid']   = (string) $this->userId;
        $params['password'] = (string) $this->password;

        return $params;
    }

    /**
     * @param array<string, string> $headers
     * @return array<string, string>
     */
    public function applyToHeaders(array $headers): array
    {
        if ($this->apiKey !== null) {
            $headers['apikey'] = $this->apiKey;
        }

        return $headers;
    }

    public function __debugInfo(): array
    {
        return ['auth' => $this->apiKey !== null ? 'apiKey(redacted)' : 'basic(redacted)'];
    }
}

That __debugInfo override is small and worth copying. It means var_dump, Xdebug and most error reporters print apiKey(redacted) instead of your live credential when this object appears in a stack trace.


8. Sending to Many Recipients Without Melting Your Web Server

You have three options, and the right one depends on volume and personalisation.

ApproachsendMethodPersonalised textBest volume rangeTrade-off
Comma-separated recipients in one callquickNo, one body for allTens to low hundredsSimple, but one failure response covers many numbers
Saved groupgroupNoRecurring sends to a stable listList lives on the platform, not in your database
File uploadfileDepends on file formatLarge one-off campaignsAsynchronous, needs a separate status check
One call per recipient from a queuequickYesAny, with concurrency controlMost HTTP calls, but full per-message control

For transactional traffic, which is the overwhelming majority of what a PHP application sends, the last row is almost always correct. Each message has its own text, its own DLT template, its own idempotency key and its own retry state. Batching them removes all four.

Here is a concurrency-controlled sender using Guzzle’s pool, which is the right tool when you genuinely need throughput from PHP and do not have a queue worker fleet.

<?php
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\ResponseInterface;

/**
 * @param list<array{mobile: string, msg: string}> $messages
 * @return array<string, string> mobile => transactionId or error reason
 */
function sendConcurrently(Client $http, array $messages, string $apiKey, string $senderId, int $concurrency = 10): array
{
    $results = [];

    $requests = static function () use ($messages, $apiKey, $senderId): \Generator {
        foreach ($messages as $index => $m) {
            $body = http_build_query([
                'sendMethod' => 'quick',
                'mobile'     => $m['mobile'],
                'msg'        => $m['msg'],
                'senderid'   => $senderId,
                'msgType'    => 'text',
                'output'     => 'json',
            ]);

            yield $index => new Request(
                'POST',
                'https://unify.smsgateway.center/SMSApi/send',
                [
                    'apikey'       => $apiKey,
                    'Content-Type' => 'application/x-www-form-urlencoded',
                ],
                $body
            );
        }
    };

    $pool = new Pool($http, $requests(), [
        'concurrency' => $concurrency,
        'fulfilled'   => static function (ResponseInterface $response, int $index) use (&$results, $messages): void {
            $decoded = json_decode((string) $response->getBody(), true);
            $results[$messages[$index]['mobile']] = $decoded['status'] === 'success'
                ? (string) $decoded['transactionId']
                : 'ERROR: ' . ($decoded['reason'] ?? 'unknown');
        },
        'rejected'    => static function (\Throwable $e, int $index) use (&$results, $messages): void {
            $results[$messages[$index]['mobile']] = 'TRANSPORT_FAILURE: ' . $e->getMessage();
        },
    ]);

    $pool->promise()->wait();

    return $results;
}

Set concurrency conservatively. Opening fifty simultaneous connections to any gateway from a single PHP process is a good way to get throttled, and the correct ceiling depends on the throughput provisioned on your account. If you are pushing enough volume that HTTP concurrency is the bottleneck, you have outgrown HTTP: move to a persistent SMPP bind, and read SMPP rate limits and throughput tuning before you do.

Message Length and Encoding

A GSM-7 SMS holds 160 characters. Add one character outside the GSM-7 alphabet, including a curly apostrophe pasted from a word processor, and the whole message switches to UCS-2 at 70 characters, splitting into multiple billed parts. In PHP, the trap is that strlen counts bytes and mb_strlen counts characters, and neither counts SMS segments, because GSM-7 has extension characters ({, }, [, ], ~, ^, \, |, ) that consume two septets each.

<?php
declare(strict_types=1);

final class GsmSegmentCounter
{
    private const GSM7_BASIC = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
        . "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";

    private const GSM7_EXTENDED = "^{}\\[~]|€";

    public static function isGsm7(string $text): bool
    {
        foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {
            if (!str_contains(self::GSM7_BASIC, $char) && !str_contains(self::GSM7_EXTENDED, $char)) {
                return false;
            }
        }

        return true;
    }

    /** @return array{encoding: string, units: int, segments: int} */
    public static function measure(string $text): array
    {
        if (!self::isGsm7($text)) {
            $units = mb_strlen($text, 'UTF-8');
            $segments = $units <= 70 ? 1 : (int) ceil($units / 67);

            return ['encoding' => 'UCS-2', 'units' => $units, 'segments' => max(1, $segments)];
        }

        $units = 0;
        foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {
            $units += str_contains(self::GSM7_EXTENDED, $char) ? 2 : 1;
        }

        $segments = $units <= 160 ? 1 : (int) ceil($units / 153);

        return ['encoding' => 'GSM-7', 'units' => $units, 'segments' => max(1, $segments)];
    }
}

Run this before you send and log the result. A campaign that silently doubled in cost because someone pasted a smart quote is a support ticket you can avoid entirely. There is also a browser-based SMS length calculator for one-off checks.

When the message is genuinely non-Latin, set msgType to unicode rather than text.


9. India Specific: DLT Entity IDs, Template IDs and Pre-Send Validation

If any of your recipients are Indian numbers, this section is the difference between a working integration and one that returns errors you cannot interpret.

Under TRAI‘s regulatory framework, commercial SMS to Indian subscribers must be sent from a registered principal entity, using a registered header (sender ID), against a template registered on a DLT platform. The gateway does not decide this, the operator’s scrubbing engine does, and it compares your submitted text against the registered template before delivery. A mismatch of a single character, including a trailing space or a substituted punctuation mark, gets the message scrubbed. Background reading on the mechanics sits in the PE-TM binding chain explainer.

In practice this means two extra parameters on every India-bound send:

$params['dltEntityId']   = '1201159xxxxxxxxxxx';
$params['dltTemplateId'] = '1707162xxxxxxxxxxx';

Validate Before You Send

The mistake most teams make is discovering the mismatch from a delivery report hours later. There is a pre-flight endpoint for exactly this: POST https://unify.smsgateway.center/SMSApi/validateTemplate, documented at Validate Template. It takes your senderid, the fully rendered msg and the dltTemplateId, and tells you whether the operator will accept it.

<?php
declare(strict_types=1);

namespace App\Sms;

final class DltValidator
{
    public function __construct(private readonly SmsGatewayClient $client) {}

    public function isValid(string $senderId, string $renderedMessage, string $dltTemplateId): bool
    {
        $raw = $this->client->post('SMSApi/validateTemplate', [
            'senderid'      => $senderId,
            'msg'           => $renderedMessage,
            'dltTemplateId' => $dltTemplateId,
        ]);

        if (($raw['status'] ?? '') === 'success') {
            return true;
        }

        // 188 is the documented template mismatch code.
        if ((string) ($raw['statusCode'] ?? '') === '188') {
            throw new DltTemplateMismatchException(
                sprintf(
                    'Rendered message does not match DLT template %s. Gateway said: %s',
                    $dltTemplateId,
                    (string) ($raw['reason'] ?? 'message template mismatch')
                )
            );
        }

        throw new SmsApiException((string) ($raw['reason'] ?? 'Template validation failed.'));
    }
}

Wire this into your CI pipeline, not just your runtime. Render every template in your codebase with representative sample values, call validateTemplate on each, and fail the build on a mismatch. A DLT template drift caught at merge time costs nothing; the same drift caught in production costs you every OTP sent between deploy and detection.

Rendering Templates Correctly

DLT variable syntax is {#var#}. The registered template might read:

Dear {#var#}, your order {#var#} has been shipped and will arrive by {#var#}. Team ACME

The corresponding PHP renderer must substitute positionally and must not alter anything else, including trailing whitespace and punctuation.

<?php
declare(strict_types=1);

namespace App\Sms;

final class DltTemplateRenderer
{
    private const PLACEHOLDER = '{#var#}';

    /** @param list<string> $values */
    public function render(string $template, array $values): string
    {
        $expected = substr_count($template, self::PLACEHOLDER);

        if ($expected !== count($values)) {
            throw new \InvalidArgumentException(
                sprintf('Template expects %d variables, %d given.', $expected, count($values))
            );
        }

        $result = $template;
        foreach ($values as $value) {
            $pos = strpos($result, self::PLACEHOLDER);
            if ($pos === false) {
                break;
            }
            $result = substr_replace($result, $value, $pos, strlen(self::PLACEHOLDER));
        }

        return $result;
    }
}

Use substr_replace in a loop rather than str_replace, because str_replace with an array of subjects has surprising behaviour when a substituted value itself contains the placeholder text, and because positional replacement makes the ordering contract explicit.

Never call trim(), ucfirst(), htmlspecialchars() or any normaliser on the rendered output before sending. Every one of those can produce a mismatch against a template you registered without that transformation.

Creating Templates Programmatically

If you manage many templates, POST https://unify.smsgateway.center/SMSApi/template/create accepts a message parameter containing the template content and submits it for approval. See Create Message Template. Approval is asynchronous, so treat template creation as a provisioning workflow with its own state machine, not as something you call inline during a send.


10. OTP in PHP: Generate and Verify Without Rolling Your Own

This is the section most PHP developers need and most tutorials skip. The instinct is to generate a six digit code with random_int(100000, 999999), stuff it in Redis with a TTL, send it as a normal SMS, and compare it on submission. That works, and it also means you now own OTP expiry, replay protection, resend throttling, attempt limiting and the timing-safe comparison. Four of those five are commonly implemented wrongly.

The API exposes a dedicated OTP endpoint at https://unify.smsgateway.center/SMSApi/otp which handles generation, delivery, expiry and verification server side.

Generate

Per the Generate OTP documentation, you send sendMethod=generate along with mobile, msgType, msg (containing the literal $otp$ placeholder, which the platform substitutes), medium=sms, codeType (num, alpha or alphanum), codeExpiry in seconds (default 300), codeLength, senderid and output.

<?php
declare(strict_types=1);

namespace App\Sms;

final class OtpService
{
    public function __construct(
        private readonly SmsGatewayClient $client,
        private readonly string $senderId,
    ) {
    }

    /** @return array{createTime: string, expiryTime: string, retryAfter: string} */
    public function generate(string $msisdn, string $messageTemplate, int $length = 6, int $expirySeconds = 300): array
    {
        $raw = $this->client->post('SMSApi/otp', [
            'sendMethod' => 'generate',
            'mobile'     => $msisdn,
            'msgType'    => 'text',
            'msg'        => $messageTemplate, // must contain the literal $otp$ placeholder
            'medium'     => 'sms',
            'codeType'   => 'num',
            'codeExpiry' => $expirySeconds,
            'codeLength' => $length,
            'senderid'   => $this->senderId,
        ]);

        if (($raw['status'] ?? '') !== 'success') {
            throw new SmsApiException((string) ($raw['reason'] ?? 'OTP generation failed.'));
        }

        return [
            'createTime' => (string) $raw['createTime'],
            'expiryTime' => (string) $raw['expiryTime'],
            'retryAfter' => (string) $raw['retryAfter'],
        ];
    }

    public function verify(string $msisdn, string $submittedCode): bool
    {
        $raw = $this->client->post('SMSApi/otp', [
            'sendMethod' => 'verify',
            'mobile'     => $msisdn,
            'otp'        => $submittedCode,
        ]);

        return ($raw['status'] ?? '') === 'success';
    }
}

A successful generate returns:

{
    "status": "success",
    "mobile": "91777xxxxxxx",
    "transactionId": "0",
    "statusCode": "300",
    "type": "new",
    "reason": "OTP successfully generated.",
    "createTime": "1700117933163",
    "expiryTime": "1700118233163",
    "retryAfter": "1700117993163"
}

A successful verify returns statusCode 301 with the reason OTP has been Verified Successfully.

Use retryAfter, Do Not Invent Your Own Resend Timer

retryAfter is a millisecond epoch timestamp telling you the earliest moment a resend is permitted. Surface it to your frontend as a countdown rather than hardcoding thirty seconds in JavaScript. Hardcoded timers drift out of sync with server-side policy and produce the worst possible user experience: a “Resend” button that is enabled and then fails.

$otp = $otpService->generate($msisdn, $template);

$resendAvailableAt = (int) round(((int) $otp['retryAfter']) / 1000);
$secondsUntilResend = max(0, $resendAvailableAt - time());

return response()->json([
    'expires_in'      => max(0, ((int) round(((int) $otp['expiryTime']) / 1000)) - time()),
    'resend_in'       => $secondsUntilResend,
]);

OTP Is the One Case for a Synchronous Send

Everywhere else in this article the advice is to queue. OTP is the exception, because a queued OTP that takes eight seconds to leave the queue is an OTP the user has already given up on. Send it inline, but defend the request thread:

public function sendLoginOtp(string $msisdn): array
{
    if ($this->breaker->isOpen()) {
        throw new OtpChannelUnavailableException('SMS channel temporarily unavailable.');
    }

    try {
        return $this->otpService->generate($msisdn, $this->template);
    } catch (SmsTransportException $e) {
        $this->breaker->recordFailure();
        throw new OtpChannelUnavailableException('Could not send the code, please try again.', 0, $e);
    }
}

Short timeouts (five seconds total, not fifteen) plus a circuit breaker that trips after a run of consecutive failures keeps a gateway outage from taking your login page down with it.

Also worth knowing before you commit to SMS as your only OTP channel: SMS fails observably and reports back, which is why it remains the safest primary OTP channel in India. A comparison of the failure characteristics against WhatsApp lives in SMS API vs WhatsApp Business API.


11. Handling Delivery Reports in PHP

An HTTP 200 with status: success from the send endpoint means the platform accepted the message. It says nothing about whether the handset received it. The gap between those two facts is where every “but I sent it” support ticket lives.

Illustration about Handling Delivery Reports in PHP

There are two ways to close the gap, and mature integrations use both.

Polling SMSApi/reports/statusWebhook push
Latency to knowPoll interval, typically minutesNear real time
Infrastructure neededA cron and an outbound connectionA public HTTPS endpoint you must keep up
Behaviour during your outageNothing lost, you catch up on next pollDepends on retry policy, gaps are possible
Cost at scaleMore requests, mostly returning nothing newOne request per state change
Best used asReconciliation backstopPrimary signal

Polling for Delivery Reports

The DLR endpoint is https://unify.smsgateway.center/SMSApi/reports/status with method=getDlr, documented at SMS Delivery Report. It takes fromdate, todate, pageLimit, an optional mobileNo or uuId, and an optional startCursor for pagination.

<?php
declare(strict_types=1);

namespace App\Sms;

final class DeliveryReportPoller
{
    public function __construct(private readonly SmsGatewayClient $client) {}

    /**
     * @return \Generator<int, array<string, mixed>>
     */
    public function fetch(\DateTimeImmutable $from, \DateTimeImmutable $to, int $pageLimit = 100): \Generator
    {
        $cursor = null;

        do {
            $params = [
                'method'    => 'getDlr',
                'fromdate'  => $from->format('Y-m-d'),
                'todate'    => $to->format('Y-m-d'),
                'pageLimit' => $pageLimit,
            ];

            if ($cursor !== null) {
                $params['startCursor'] = $cursor;
            }

            $raw  = $this->client->post('SMSApi/reports/status', $params);
            $rows = $raw['reports_dlrList'] ?? [];

            if ($rows === []) {
                return;
            }

            foreach ($rows as $row) {
                yield $row;
            }

            $last   = $rows[array_key_last($rows)];
            $cursor = $last['cursorId'] ?? null;
        } while ($cursor !== null && count($rows) === $pageLimit);
    }
}

Each row in reports_dlrList carries country, amount, msgType, cost, deliveryTime, length, channel, msgId, cause, mobileNo, uuId, dltTemplateId, globalErrorCode, cursorId, network, senderName, flashMsg, submitTime, text and status.

Persist cost and length alongside the delivery status. Reconciling billed segments against what your GsmSegmentCounter predicted is the fastest way to catch an encoding regression, and it gives you a real per-message cost figure rather than an assumed one.

Receiving Webhooks

Register a callback URL with POST https://unify.smsgateway.center/SMSApi/webhook/create, passing smswebhook (your URL) and smswebhookrate (the DLR throughput you want, default 10), documented at Create Webhook.

$client->post('SMSApi/webhook/create', [
    'smswebhook'     => 'https://api.example.com/webhooks/sms-dlr',
    'smswebhookrate' => 10,
]);

A successful registration returns:

{
  "response": {
    "api": "webhook",
    "action": "create",
    "status": "success",
    "msg": "Webhook added successfully.",
    "code": "200"
  }
}

Set smswebhookrate to a number your receiving endpoint can actually sustain. If you register 100 and your PHP-FPM pool handles 20 requests per second while also serving users, you have created a self-inflicted denial of service. Start low, measure, raise.

On the receiving side, the rule is: acknowledge fast, process later. A DLR receiver that does a database write, a cache invalidation and an internal notification before returning 200 will fall behind under burst load, and the gateway will start seeing timeouts.

<?php
declare(strict_types=1);

// public/webhooks/sms-dlr.php
// Front controller pattern: accept, enqueue, acknowledge. Nothing else.

require __DIR__ . '/../../vendor/autoload.php';

$raw = file_get_contents('php://input') ?: '';

// Cheap shared-secret check in the query string, over HTTPS only.
if (!hash_equals(getenv('SGC_WEBHOOK_SECRET') ?: '', $_GET['token'] ?? '')) {
    http_response_code(403);
    exit;
}

// Persist the raw payload verbatim and return immediately.
// A worker parses it out of band.
$queue = new \App\Queue\RedisQueue();
$queue->push('sms.dlr.received', [
    'received_at' => microtime(true),
    'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? null,
    'content_type'=> $_SERVER['CONTENT_TYPE'] ?? null,
    'raw'         => $raw,
    'query'       => $_GET,
]);

http_response_code(200);
header('Content-Type: application/json');
echo '{"ok":true}';

Store the raw body verbatim rather than parsing it inline. That single decision means a payload format you did not anticipate is a replayable record instead of a lost delivery report. When you build the worker that parses these records, log the first few payloads and derive your parser from what actually arrives, and cross-check the semantics of each status value against the delivery report knowledge base article and the individual status explainers such as IN_RETRY and ABSENT_SUB.

Also make the receiver idempotent. Any webhook system can deliver the same event twice. Key on the message identifier plus the status value and use an upsert, so a duplicate delivery is a no-op rather than a double-counted metric.


12. Error Handling, Retries and Idempotency

Classify Before You Retry

Every failure falls into one of three buckets, and each bucket gets different handling.

ClassExamplesRetry?BackoffAction
TransportDNS failure, connection refused, TLS handshake failure, read timeoutYesExponential with jitterRetry up to a bounded attempt count
Transient applicationGateway 5xx, throttling, temporary operator unavailabilityYesExponential with jitter, longer baseRetry, then park for manual review
PermanentInvalid sender ID, DLT template mismatch, malformed recipient, insufficient balance, blocked senderNoNot applicableFail fast, alert, do not consume retry budget

The authoritative lists live at API Response Error Codes and Delivery Error Codes, and both are also fetchable programmatically via POST SMSApi/info/responsecodes and POST SMSApi/info/deliverycodes. Pull them at deploy time and cache them, rather than hardcoding a list that goes stale.

<?php
declare(strict_types=1);

namespace App\Sms;

enum FailureClass
{
    case Transport;
    case TransientApplication;
    case Permanent;
}

final class FailureClassifier
{
    /** @var list<string> Codes known to be permanent for this integration. */
    private const PERMANENT_CODES = ['188']; // template mismatch

    public function classify(?\Throwable $exception, ?SendResult $result): FailureClass
    {
        if ($exception instanceof SmsTransportException) {
            return FailureClass::Transport;
        }

        if ($result === null) {
            return FailureClass::Transport;
        }

        if (in_array($result->statusCode, self::PERMANENT_CODES, true)) {
            return FailureClass::Permanent;
        }

        // Anything the gateway explicitly rejected with a business reason is
        // permanent unless it appears in the transient allowlist you build
        // from the published response-code list.
        return $result->accepted ? FailureClass::TransientApplication : FailureClass::Permanent;
    }
}

Keep PERMANENT_CODES and its transient counterpart in configuration, populated from the published code lists, not scattered through your codebase. A deeper treatment of retry policy design, including how to size the retry window against message time-to-live, is in SMS API retry strategy: handling failed messages.

Exponential Backoff With Jitter

<?php
declare(strict_types=1);

function backoffDelaySeconds(int $attempt, int $baseSeconds = 2, int $capSeconds = 300): int
{
    $exponential = min($capSeconds, $baseSeconds * (2 ** ($attempt - 1)));

    // Full jitter. Without this, a gateway blip synchronises every one of your
    // workers to retry at the same instant and you re-create the outage.
    return random_int(0, $exponential);
}

Full jitter matters more than the exponential curve does. If a thousand queued messages all fail at once and all retry after exactly four seconds, you have built a thundering herd that guarantees the second attempt also fails.

Idempotency

The scenario is mundane and expensive. Your worker POSTs a send. The gateway accepts it and dispatches the SMS. The response is lost on the way back because a load balancer dropped the connection. Your worker sees a transport failure, classifies it as retryable, and sends the message again. The customer receives two identical OTPs, or worse, two identical payment confirmations.

The gateway’s duplicatecheck parameter helps, but you should not depend on a platform-side window you do not control. Own it.

<?php
declare(strict_types=1);

namespace App\Sms;

use Psr\SimpleCache\CacheInterface;

final class IdempotentSender
{
    private const TTL_SECONDS = 86_400;

    public function __construct(
        private readonly SmsGatewayClient $client,
        private readonly CacheInterface $cache,
    ) {
    }

    public function send(OutboundMessage $message, string $businessKey): SendResult
    {
        // The key must be derived from the business event, not from a random
        // value generated inside the retry loop, or every retry gets a new key.
        $key = 'sms:idem:' . hash('sha256', $businessKey . '|' . $message->msisdn . '|' . $message->text);

        /** @var array<string, mixed>|null $cached */
        $cached = $this->cache->get($key);
        if ($cached !== null) {
            return SendResult::fromArray($cached);
        }

        // Claim the key before sending so a concurrent worker cannot duplicate.
        $this->cache->set($key . ':lock', 1, 120);

        $result = $this->client->send($message);

        if ($result->accepted) {
            $this->cache->set($key, [
                'status'        => 'success',
                'transactionId' => $result->transactionId,
                'statusCode'    => $result->statusCode,
                'reason'        => $result->reason,
            ], self::TTL_SECONDS);
        }

        return $result;
    }
}

The critical detail is where $businessKey comes from. It must be stable across retries: an order ID, an invoice number, a login session identifier. uniqid() generated inside the send method defeats the entire mechanism, because each retry produces a different key.


13. Laravel Integration

Laravel supplies the queue, the retry semantics and the configuration layer, so most of the plumbing above collapses into framework idioms.

Configuration

<?php
// config/sms.php

return [
    'base_uri'   => env('SGC_BASE_URI', 'https://unify.smsgateway.center/'),
    'api_key'    => env('SGC_API_KEY'),
    'userid'     => env('SGC_USERID'),
    'password'   => env('SGC_PASSWORD'),
    'sender_id'  => env('SGC_SENDER_ID'),
    'dlt' => [
        'enabled'   => (bool) env('SGC_DLT_ENABLED', true),
        'entity_id' => env('SGC_DLT_ENTITY_ID'),
    ],
    'timeouts' => [
        'connect' => (float) env('SGC_CONNECT_TIMEOUT', 5.0),
        'total'   => (float) env('SGC_TIMEOUT', 15.0),
    ],
];

Never call env() outside a config file in Laravel. Once php artisan config:cache runs in production, env() returns null everywhere except within config files, and your integration silently authenticates with empty credentials.

Service Provider

<?php
declare(strict_types=1);

namespace App\Providers;

use App\Sms\SmsCredentials;
use App\Sms\SmsGatewayClient;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use Illuminate\Support\ServiceProvider;

final class SmsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(SmsGatewayClient::class, function ($app) {
            $config = $app['config']['sms'];

            $credentials = $config['api_key']
                ? SmsCredentials::fromApiKey($config['api_key'])
                : SmsCredentials::fromBasic($config['userid'], $config['password']);

            $http = new Client([
                'base_uri'                      => $config['base_uri'],
                RequestOptions::CONNECT_TIMEOUT => $config['timeouts']['connect'],
                RequestOptions::TIMEOUT         => $config['timeouts']['total'],
                RequestOptions::HTTP_ERRORS     => false,
            ]);

            return new SmsGatewayClient($http, $credentials);
        });
    }
}

The Queued Job

<?php
declare(strict_types=1);

namespace App\Jobs;

use App\Sms\FailureClass;
use App\Sms\FailureClassifier;
use App\Sms\OutboundMessage;
use App\Sms\SmsGatewayClient;
use App\Sms\SmsTransportException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class SendSmsJob implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 5;
    public int $timeout = 30;
    public int $uniqueFor = 3600;

    public function __construct(
        private readonly OutboundMessage $message,
        private readonly string $businessKey,
    ) {
    }

    public function uniqueId(): string
    {
        return $this->businessKey . ':' . $this->message->msisdn;
    }

    /** @return list<int> Full-jitter backoff, in seconds, per attempt. */
    public function backoff(): array
    {
        return [random_int(1, 4), random_int(2, 15), random_int(5, 60), random_int(15, 180)];
    }

    public function handle(SmsGatewayClient $client, FailureClassifier $classifier): void
    {
        try {
            $result = $client->send($this->message);
        } catch (SmsTransportException $e) {
            // Let the queue retry. Transport failures are retryable by definition.
            throw $e;
        }

        if ($result->accepted) {
            SmsTransaction::record($this->businessKey, $this->message->msisdn, $result->transactionId);
            return;
        }

        if ($classifier->classify(null, $result) === FailureClass::Permanent) {
            // Do not burn retries on a message that will never be accepted.
            $this->fail(new \RuntimeException(
                "Permanent SMS failure {$result->statusCode}: {$result->reason}"
            ));
            return;
        }

        throw new \RuntimeException("Transient SMS failure {$result->statusCode}: {$result->reason}");
    }
}

ShouldBeUnique combined with a uniqueId derived from the business key gives you queue-level deduplication for free, which covers the case where your application dispatches the same job twice. It does not cover the lost-response case from section 12, so keep both.

The $this->fail() call on a permanent failure is what stops a message with an invalid sender ID from occupying a worker five times over ten minutes before landing in the failed jobs table anyway.

Dispatching

SendSmsJob::dispatch(
    new OutboundMessage(
        msisdn: $order->customer->msisdn,
        text: $renderer->render($template->body, [$order->customer->first_name, (string) $order->id, $order->eta->format('d M')]),
        senderId: config('sms.sender_id'),
        dltEntityId: config('sms.dlt.entity_id'),
        dltTemplateId: $template->dlt_template_id,
    ),
    businessKey: 'order.shipped:' . $order->id,
)->onQueue('sms');

Put SMS on its own queue with its own workers. Sharing a queue with report generation means a slow report blocks a shipping notification.


14. Security: Credentials, Input Validation and Log Hygiene

Credentials belong in the environment, never in the repository. That is the well-known part. The less well-known part is that .env files get read by more things than you expect. Make sure your webroot is public/ and not the project root, and confirm it directly by requesting /.env against your staging deployment. Finding it returns a 200 is a bad way to learn this.

Validate and normalise recipient numbers before they reach the gateway. User-supplied phone numbers arrive with spaces, hyphens, leading zeros, + prefixes and occasionally letters. Normalise to E.164 without the plus, and reject anything that does not parse.

<?php
declare(strict_types=1);

use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

function normaliseMsisdn(string $input, string $defaultRegion = 'IN'): string
{
    $util = PhoneNumberUtil::getInstance();

    try {
        $parsed = $util->parse($input, $defaultRegion);
    } catch (\libphonenumber\NumberParseException $e) {
        throw new \InvalidArgumentException("Unparseable phone number: {$input}", 0, $e);
    }

    if (!$util->isValidNumber($parsed)) {
        throw new \InvalidArgumentException("Invalid phone number: {$input}");
    }

    // E.164 without the leading plus, which is what the API expects.
    return ltrim($util->format($parsed, PhoneNumberFormat::E164), '+');
}

Install with composer require giggsey/libphonenumber-for-php. Regex-based validation of international numbers is a well-documented dead end and the numbering plans change more often than your regex will.

Never interpolate unvalidated user input into a message body. An attacker who controls part of an SMS body controls a message that arrives under your brand’s sender ID. Whitelist which fields a template accepts and cap their length.

Redact credentials and message bodies from logs. Message bodies contain OTPs, order values and names. Under most data protection regimes they are personal data.

<?php
declare(strict_types=1);

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;

final class SmsLogRedactor implements ProcessorInterface
{
    private const SENSITIVE = ['password', 'apikey', 'apiKey', 'otp', 'msg'];

    public function __invoke(LogRecord $record): LogRecord
    {
        $context = $record->context;

        foreach (self::SENSITIVE as $key) {
            if (isset($context[$key])) {
                $context[$key] = '[redacted]';
            }
        }

        if (isset($context['mobile']) && is_string($context['mobile'])) {
            $context['mobile'] = substr($context['mobile'], 0, 4) . str_repeat('*', max(0, strlen($context['mobile']) - 6)) . substr($context['mobile'], -2);
        }

        return $record->with(context: $context);
    }
}

Verify webhook authenticity. The receiver in section 11 uses a shared secret in the query string over HTTPS, which is the minimum. If your infrastructure allows it, additionally restrict the endpoint by source IP, and always terminate TLS properly rather than accepting plain HTTP on the callback URL.

Rate limit outbound sends per recipient. An unbounded “resend code” button is an SMS pumping vector: an attacker triggers thousands of sends to numbers on a premium route and you pay for all of them. Cap resends per number per hour at the application layer, on top of the platform’s retryAfter.


15. Testing Your Integration Without Sending Real SMS

You cannot write meaningful tests against an integration that charges you per assertion. Guzzle’s MockHandler solves this.

<?php
declare(strict_types=1);

namespace Tests\Unit\Sms;

use App\Sms\OutboundMessage;
use App\Sms\SmsCredentials;
use App\Sms\SmsGatewayClient;
use App\Sms\SmsTransportException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;

final class SmsGatewayClientTest extends TestCase
{
    /** @var list<array{request: \Psr\Http\Message\RequestInterface}> */
    private array $history = [];

    private function clientFor(MockHandler $mock): SmsGatewayClient
    {
        $stack = HandlerStack::create($mock);
        $stack->push(Middleware::history($this->history));

        return new SmsGatewayClient(
            new Client(['handler' => $stack, 'http_errors' => false]),
            SmsCredentials::fromApiKey('test-key'),
        );
    }

    public function testSuccessfulSendReturnsTransactionId(): void
    {
        $client = $this->clientFor(new MockHandler([
            new Response(200, [], json_encode([
                'status'        => 'success',
                'mobile'        => '919999999999',
                'invalidMobile' => '',
                'transactionId' => 'txn-123',
                'statusCode'    => '200',
                'reason'        => 'success',
            ], JSON_THROW_ON_ERROR)),
        ]));

        $result = $client->send(new OutboundMessage(
            msisdn: '919999999999',
            text: 'Hello',
            senderId: 'SMSGAT',
        ));

        self::assertTrue($result->accepted);
        self::assertSame('txn-123', $result->transactionId);
    }

    public function testApiKeyIsSentAsHeaderAndNotInBody(): void
    {
        $client = $this->clientFor(new MockHandler([
            new Response(200, [], '{"status":"success","statusCode":"200","reason":"success"}'),
        ]));

        $client->send(new OutboundMessage('919999999999', 'Hello', 'SMSGAT'));

        $request = $this->history[0]['request'];

        self::assertSame('test-key', $request->getHeaderLine('apikey'));
        self::assertStringNotContainsString('password', (string) $request->getBody());
    }

    public function testTemplateMismatchIsSurfacedAsPermanentFailure(): void
    {
        $client = $this->clientFor(new MockHandler([
            new Response(200, [], json_encode([
                'status'     => 'error',
                'statusCode' => '188',
                'reason'     => 'message template mismatch',
            ], JSON_THROW_ON_ERROR)),
        ]));

        $result = $client->send(new OutboundMessage('919999999999', 'Wrong text', 'SMSGAT'));

        self::assertFalse($result->accepted);
        self::assertSame('188', $result->statusCode);
    }

    public function testConnectionFailureBecomesTransportException(): void
    {
        $client = $this->clientFor(new MockHandler([
            new ConnectException('Connection refused', new Request('POST', 'SMSApi/send')),
        ]));

        $this->expectException(SmsTransportException::class);

        $client->send(new OutboundMessage('919999999999', 'Hello', 'SMSGAT'));
    }
}

Four tests, four distinct failure modes, zero SMS sent and zero rupees spent. The second test is the one people skip and the one that catches a real regression: it asserts the credential travels in the header and never appears in the body.

Add a DLT rendering test that asserts the rendered output is byte-identical to the registered template with variables substituted, and run validateTemplate against a staging account as an integration test in CI. The unit test catches renderer bugs; the integration test catches template drift on the DLT portal side, which no unit test can see.


16. Performance and Scaling Notes

Reuse the HTTP client. Constructing a fresh Guzzle client per message means a fresh TLS handshake per message. In a long-running worker, build the client once and inject it.

Enable connection reuse in workers. PHP-FPM tears down between requests, so keep-alive buys you nothing in a web request context. In a queue worker or a CLI daemon it is significant. This is one more argument for moving sends out of the request cycle.

Watch worker memory. Long-running Laravel workers accumulate memory. Use --max-jobs and --max-time so workers cycle before they bloat, and set --memory below your container limit so the worker exits cleanly rather than being killed mid-send.

Know when HTTP is the wrong protocol. HTTP submit has per-request overhead that a persistent binary session does not. If you are sending sustained high volume rather than bursts, a persistent SMPP bind is the right transport, and PHP is usually the wrong language for the binding process. Details in the SMPP connectivity documentation, with tuning guidance in SMPP rate limits and throughput tuning.

Instrument four numbers. Submit latency at the 95th percentile, submit acceptance rate, time from submit to final DLR, and delivered rate. The first two tell you about your integration. The last two tell you about the route. Confusing them sends you debugging your PHP when the problem is an operator.


17. Production Readiness Checklist

ItemWhy it mattersDone
Sends happen off the request threadA slow gateway cannot exhaust your FPM pool
Connect and total timeouts set explicitlycURL’s default is effectively infinite
API key auth, not userid and passwordRotatable, revocable, header-only
Credentials in environment, .env outside webrootConfirmed by requesting /.env on staging
Recipient numbers normalised to E.164 with libphonenumberRegex validation of international numbers fails
Segment count and encoding computed before sendCatches accidental UCS-2 cost doubling
DLT template validated in CI via validateTemplateCatches drift at merge, not in production
Idempotency key derived from a stable business eventPrevents duplicate sends on lost responses
Failures classified before retryingStops retry storms on permanent errors
Backoff includes full jitterPrevents thundering herd on recovery
transactionId persisted against the business recordWithout it you cannot correlate a DLR
DLR ingestion running, webhook or polling or bothHTTP 200 is not delivery
Webhook receiver acknowledges before processingPrevents backpressure and gateway timeouts
Webhook receiver is idempotentDuplicate deliveries are normal
Message bodies and credentials redacted from logsOTPs and personal data in logs are a breach
Per-recipient send rate limitBlocks SMS pumping through resend buttons
Alert on acceptance rate drop and delivered rate dropSilent failure is the expensive failure

Ready to send your first message? Create an account, generate an API key from the control panel, and run the cURL snippet from section 5 against your own sender ID. If you are sending to Indian numbers, register your DLT entity and templates first. Get started or browse the full API reference.


18. Common Mistakes and How to Fix Them

Treating HTTP 200 as delivered. It means accepted. Persist the transactionId and reconcile against a DLR before you tell a user their code was sent.

Building the query string by concatenation. A password containing & or + breaks silently and looks exactly like an authentication failure. Use http_build_query or Guzzle’s form_params.

Retrying permanent failures. An invalid sender ID retried with exponential backoff is still invalid twelve minutes later, and you have burned a worker for the duration. Classify first.

Generating the idempotency key inside the retry loop. Every retry gets a new key and the mechanism does nothing. Derive it from the business event upstream of the send.

Calling trim() on a rendered DLT template. Any normalisation applied after rendering and before sending can produce a mismatch with what you registered. Render, validate, send, unchanged.

Registering a webhook rate your endpoint cannot serve. smswebhookrate is a promise you are making about your own capacity. Start at the default and raise it after you have measured.

Using strlen to check message length. It counts bytes. A message with a single accented character will be reported as longer than it is in characters and shorter than it is in segments. Use a proper segment counter.

Parsing webhook payloads inline in the receiver. Store raw, acknowledge, parse in a worker. An unexpected format becomes a replayable record instead of a lost report.

Calling env() outside config files in Laravel. After config:cache, it returns null and your integration authenticates with nothing.

Logging the full request payload at debug level in production. It works fine until an auditor reads the log and finds every OTP your system has ever sent.


19. FAQs

How do I send an SMS in PHP? POST to https://unify.smsgateway.center/SMSApi/send using cURL or Guzzle with form-encoded parameters: authentication (userid and password, or an apikey header), sendMethod=quick, mobile, msg, senderid, msgType=text and output=json. Parse the JSON response and store the returned transactionId. A complete working example is in section 5.

Do I need Composer to integrate an SMS API in PHP? No. The raw cURL example in section 5 has no dependencies beyond the bundled cURL extension. Composer is recommended because Guzzle gives you cleaner timeout handling, a mockable transport for tests and connection reuse in workers, but it is not required to send your first message.

Should I use cURL or Guzzle? Use cURL for a one-off script or when you cannot install dependencies. Use Guzzle for anything with more than one endpoint, because it centralises timeouts and error mapping, supports concurrent requests via Pool, and can be mocked in unit tests without hitting the network.

What is the difference between userid plus password and apiKey authentication? Both authenticate the same requests. The API key travels as an HTTP header, can be rotated without changing your panel password, and can be issued per service so a leak has a limited blast radius. Use the API key in production.

Why did my SMS return success but never arrive? A success response means the platform accepted the message for delivery. Non-delivery after acceptance is a downstream event: DLT scrubbing rejection, handset unreachable, DND registration, or an operator failure. Fetch the delivery report from SMSApi/reports/status and read the cause and globalErrorCode fields.

How do I handle DLT templates in PHP? Store the registered template text and its dltTemplateId together, render variables positionally into the {#var#} placeholders without applying any other string transformation, validate the rendered output via POST SMSApi/validateTemplate before sending, and pass dltEntityId and dltTemplateId on the send call. Section 9 has the full renderer and validator.

What does status code 188 mean? It is the documented template mismatch response from the validation endpoint, returned as {"status":"error","statusCode":"188","reason":"message template mismatch"}. Your rendered message does not match the registered DLT template. Compare them character by character, including trailing whitespace and punctuation.

How do I send OTP SMS in PHP? Use the dedicated OTP endpoint at https://unify.smsgateway.center/SMSApi/otp with sendMethod=generate, supplying mobile, msg containing the literal $otp$ placeholder, codeLength, codeExpiry, codeType and senderid. Verify with the same endpoint using sendMethod=verify and the submitted otp. This delegates generation, expiry and verification to the platform instead of you implementing them.

Should OTP sends be queued like other messages? No. Queue everything else, but send OTP inline so the user is not waiting on queue latency. Protect the request thread with a short total timeout, around five seconds, and a circuit breaker that fails fast when the channel is degraded.

How do I receive delivery reports in PHP? Two options. Poll POST https://unify.smsgateway.center/SMSApi/reports/status with method=getDlr and paginate using startCursor and cursorId. Or register a callback with POST SMSApi/webhook/create supplying smswebhook and smswebhookrate, and build a receiver that stores the raw payload and returns 200 immediately. Mature integrations run the webhook as the primary signal and the poll as a reconciliation backstop.

How do I avoid sending duplicate SMS on a retry? Derive an idempotency key from a stable business identifier such as an order ID, check it before sending, and record the result against it after a successful send. The platform’s duplicatecheck parameter is a useful second layer but should not be your only defence. Section 12 has a working implementation.

How many characters can one SMS hold? 160 in GSM-7, or 70 in UCS-2 when the text contains characters outside the GSM-7 alphabet. Concatenated messages lose bytes to the segmentation header, giving 153 characters per part in GSM-7 and 67 in UCS-2. Nine GSM-7 characters, including and curly braces, consume two units each.

How do I test an SMS integration without sending real messages? Inject Guzzle’s MockHandler in place of the real handler and return canned responses for the success, template mismatch, transport failure and partial batch cases. Add Middleware::history so you can assert on the outgoing request, particularly that credentials travel in headers and not in the body. Section 15 has four ready-to-copy tests.

Can I use this from Laravel, Symfony or WordPress? Yes. The client in section 6 is framework-agnostic PHP. Section 13 shows the Laravel wiring with a service provider, a config file and a queued job. In Symfony, register the client as a service and use Messenger instead of Laravel’s queue. In WordPress, register the send as an Action Scheduler task rather than calling it during a page load.


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!