{"id":2594,"date":"2026-08-03T13:43:51","date_gmt":"2026-08-03T08:13:51","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2594"},"modified":"2026-08-03T14:21:03","modified_gmt":"2026-08-03T08:51:03","slug":"sms-api-php-integration-tutorial","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/","title":{"rendered":"SMS API in PHP: Full Integration Tutorial (Production Ready, 2026)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Table of Contents<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#executive-summary\" data-type=\"internal\" data-id=\"#executive-summary\">Executive Summary<\/a><\/li>\n\n\n\n<li><a href=\"#tl-dr\" data-type=\"internal\" data-id=\"#tl-dr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#prerequisites\" data-type=\"internal\" data-id=\"#prerequisites\">Prerequisites and Account Setup<\/a><\/li>\n\n\n\n<li><a href=\"#architecture\" data-type=\"internal\" data-id=\"#architecture\">Architecture: Where the SMS Client Belongs in a PHP Application<\/a><\/li>\n\n\n\n<li><a href=\"#your-first-send\" data-type=\"internal\" data-id=\"#your-first-send\">Your First Send: Raw PHP cURL<\/a><\/li>\n\n\n\n<li><a href=\"#guzzle\" data-type=\"internal\" data-id=\"#guzzle\">A Better Client: Guzzle and PSR-18<\/a><\/li>\n\n\n\n<li><a href=\"#authentication\" data-type=\"internal\" data-id=\"#authentication\">Authentication: userid and password versus apiKey<\/a><\/li>\n\n\n\n<li><a href=\"#sending\" data-type=\"internal\" data-id=\"#sending\">Sending to Many Recipients Without Melting Your Web Server<\/a><\/li>\n\n\n\n<li><a href=\"#india\" data-type=\"internal\" data-id=\"#india\">India Specific: DLT Entity IDs, Template IDs and Pre-Send Validation<\/a><\/li>\n\n\n\n<li><a href=\"#otp\" data-type=\"internal\" data-id=\"#otp\">OTP in PHP: Generate and Verify Without Rolling Your Own<\/a><\/li>\n\n\n\n<li><a href=\"#handling\" data-type=\"internal\" data-id=\"#handling\">Handling Delivery Reports in PHP<\/a><\/li>\n\n\n\n<li><a href=\"#error\" data-type=\"internal\" data-id=\"#error\">Error Handling, Retries and Idempotency<\/a><\/li>\n\n\n\n<li><a href=\"#laravel\" data-type=\"internal\" data-id=\"#laravel\">Laravel Integration<\/a><\/li>\n\n\n\n<li><a href=\"#security\" data-type=\"internal\" data-id=\"#security\">Security: Credentials, Input Validation and Log Hygiene<\/a><\/li>\n\n\n\n<li><a href=\"#testing\" data-type=\"internal\" data-id=\"#testing\">Testing Your Integration Without Sending Real SMS<\/a><\/li>\n\n\n\n<li><a href=\"#performance\" data-type=\"internal\" data-id=\"#performance\">Performance and Scaling Notes<\/a><\/li>\n\n\n\n<li><a href=\"#production\">Production Readiness Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#common\">Common Mistakes and How to Fix Them<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">1. Executive Summary<\/h2>\n\n\n\n<a id=\"executive-summary\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-php-integration-tutorial-architecture.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-php-integration-tutorial-architecture-1024x584.webp\" alt=\"Diagram of a PHP application sending SMS through a gateway to mobile handsets with a delivery receipt callback returning to the application.\" class=\"wp-image-2595\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-php-integration-tutorial-architecture-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-php-integration-tutorial-architecture-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-php-integration-tutorial-architecture-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-php-integration-tutorial-architecture.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">developer API documentation<\/a>, not from a generic example. If you want the language-agnostic orientation first, start with the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/send-sms-api-guide\/\">SMS API getting started guide<\/a> and come back here for the PHP implementation.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">2. TL;DR<\/h2>\n\n\n\n<a id=\"tl-dr\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">To send an SMS from PHP, POST to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> with <code>userid<\/code> and <code>password<\/code> (or an <code>apiKey<\/code> HTTP header), plus <code>sendMethod=quick<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>senderid<\/code>, <code>msgType=text<\/code> and <code>output=json<\/code>. For Indian traffic, add <code>dltEntityId<\/code> and <code>dltTemplateId<\/code>. Parse the JSON response, store the returned <code>transactionId<\/code>, and treat the HTTP 200 as &#8220;accepted for delivery&#8221;, not &#8220;delivered&#8221;. Delivery outcome arrives later, either by polling <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> or by registering a webhook through <code>https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<a id=\"prerequisites\"><\/a>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">3. Prerequisites and Account Setup<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You need four things before the first line of code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>PHP 8.1 or newer.<\/strong> 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 <a href=\"https:\/\/www.php.net\/supported-versions.php\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">7.4 has been end of life<\/a> since 2022 and should not be carrying production traffic.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The cURL extension, or Guzzle.<\/strong> Check with <code>php -m | grep curl<\/code>. If cURL is unavailable in your hosting environment, install Guzzle 7, which falls back to PHP&#8217;s stream wrapper transport when cURL is absent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>An SMSGatewayCenter account with an approved sender ID.<\/strong> Sender IDs in India are alphanumeric and capped at six characters. Create one through the panel or programmatically via <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/senderid\/create<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-sender-id\/\">Create Sender ID<\/a>. Approval is not instant, so do this before you start coding, not while you are debugging.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>For Indian traffic, a registered DLT entity and at least one approved template.<\/strong> This is not optional and it is not something you can work around. If you have not been through this yet, follow the <a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DLT registration walkthrough<\/a> first. Section 9 covers the API side.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Install Dependencies<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>composer require guzzlehttp\/guzzle:^7.8\ncomposer require --dev phpunit\/phpunit:^10.5\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">SMSGatewayCenter also publishes an official PHP SDK on Packagist. Per the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-php-sdk\/\">PHP SDK page<\/a>, you install it with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>composer require sgcsdk\/sgcapi\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then open <code>..\/vendor\/sgcsdk\/sgcapi\/src\/config\/common\/sgc_constant.php<\/code> and change the server host constant from the local default to production:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const SERVER_HOST = \"https:\/\/unify.smsgateway.center\";\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/getcomposer.org\/\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">composer<\/a> 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.<\/p>\n\n\n\n<a id=\"architecture\"><\/a>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">4. Architecture: Where the SMS Client Belongs in a PHP Application<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Structure the integration in four layers.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Layer<\/th><th>Responsibility<\/th><th>What it must not do<\/th><\/tr><\/thead><tbody><tr><td>Controller \/ entry point<\/td><td>Validate user input, decide that a message is warranted, hand off<\/td><td>Never call the gateway directly, never format a template<\/td><\/tr><tr><td>Message service<\/td><td>Build the payload, resolve sender ID and DLT template, assign an idempotency key, enqueue<\/td><td>Never open a socket<\/td><\/tr><tr><td>Queue worker<\/td><td>Execute the HTTP call, interpret the response, apply retry policy, persist the transaction ID<\/td><td>Never contain business rules about who gets messaged<\/td><\/tr><tr><td>Transport client<\/td><td>Serialise parameters, set timeouts, return a typed response object or throw a typed exception<\/td><td>Never know what a &#8220;welcome message&#8221; is<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/architecture-sms-client-php-application.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/architecture-sms-client-php-application-1024x584.webp\" alt=\"Illustration about Architecture: Where the SMS Client Belongs in a PHP Application\" class=\"wp-image-2596\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/architecture-sms-client-php-application-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/architecture-sms-client-php-application-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/architecture-sms-client-php-application-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/architecture-sms-client-php-application.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">5. Your First Send: Raw PHP cURL<\/h2>\n\n\n\n<a id=\"your-first-send\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">Start with the smallest thing that works, then refactor. The send endpoint is <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code>, form encoded.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\n$params = &#91;\n    'userid'         =&gt; getenv('SGC_USERID'),\n    'password'       =&gt; getenv('SGC_PASSWORD'),\n    'sendMethod'     =&gt; 'quick',\n    'mobile'         =&gt; '919999999999',\n    'msg'            =&gt; 'Your order 4821 has shipped. Track it at https:\/\/example.com\/t\/4821',\n    'senderid'       =&gt; 'SMSGAT',\n    'msgType'        =&gt; 'text',\n    'duplicatecheck' =&gt; 'true',\n    'output'         =&gt; 'json',\n];\n\n$ch = curl_init('https:\/\/unify.smsgateway.center\/SMSApi\/send');\ncurl_setopt_array($ch, &#91;\n    CURLOPT_RETURNTRANSFER =&gt; true,\n    CURLOPT_POST           =&gt; true,\n    CURLOPT_POSTFIELDS     =&gt; http_build_query($params),\n    CURLOPT_HTTPHEADER     =&gt; &#91;'Content-Type: application\/x-www-form-urlencoded'],\n    CURLOPT_CONNECTTIMEOUT =&gt; 5,\n    CURLOPT_TIMEOUT        =&gt; 15,\n    CURLOPT_HTTP_VERSION   =&gt; CURL_HTTP_VERSION_1_1,\n]);\n\n$body   = curl_exec($ch);\n$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);\n$err    = curl_error($ch);\ncurl_close($ch);\n\nif ($body === false) {\n    throw new RuntimeException(\"Transport failure: {$err}\");\n}\n\n$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);\nvar_dump($status, $decoded);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful response looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"mobile\": \"919999999999\",\n    \"invalidMobile\": \"\",\n    \"transactionId\": \"a1b2c3d4-...\",\n    \"statusCode\": \"200\",\n    \"reason\": \"success\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four things in that snippet are doing real work and are routinely omitted from tutorials elsewhere.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>CURLOPT_CONNECTTIMEOUT<\/code> and <code>CURLOPT_TIMEOUT<\/code> are set explicitly. cURL&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>http_build_query<\/code> handles URL encoding for you. This matters more than it looks. If your password contains a <code>+<\/code>, <code>&amp;<\/code> or <code>#<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>duplicatecheck<\/code> set to <code>true<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>JSON_THROW_ON_ERROR<\/code> means a malformed response raises an exception rather than quietly returning <code>null<\/code> and letting <code>$decoded['status']<\/code> produce a warning and a false negative.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">GET versus POST<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST 'https:\/\/unify.smsgateway.center\/SMSApi\/send' \\\n  -H 'Content-Type: application\/x-www-form-urlencoded' \\\n  --data-urlencode 'userid=YourUsername' \\\n  --data-urlencode 'password=YourPassword' \\\n  --data-urlencode 'sendMethod=quick' \\\n  --data-urlencode 'mobile=919999999999' \\\n  --data-urlencode 'msg=Test message from cURL' \\\n  --data-urlencode 'senderid=SMSGAT' \\\n  --data-urlencode 'msgType=text' \\\n  --data-urlencode 'output=json'\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you would rather explore in a GUI first, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS Postman collection<\/a> has every endpoint pre-populated.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">6. A Better Client: Guzzle and PSR-18<\/h2>\n\n\n\n<a id=\"guzzle\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Exception\\ConnectException;\nuse GuzzleHttp\\Exception\\RequestException;\nuse GuzzleHttp\\RequestOptions;\n\nfinal class SmsGatewayClient\n{\n    private const BASE_URI = 'https:\/\/unify.smsgateway.center\/';\n\n    public function __construct(\n        private readonly Client $http,\n        private readonly SmsCredentials $credentials,\n    ) {\n    }\n\n    public static function create(SmsCredentials $credentials): self\n    {\n        $http = new Client(&#91;\n            'base_uri'                  =&gt; self::BASE_URI,\n            RequestOptions::CONNECT_TIMEOUT =&gt; 5.0,\n            RequestOptions::TIMEOUT         =&gt; 15.0,\n            RequestOptions::HTTP_ERRORS     =&gt; false,\n            RequestOptions::HEADERS         =&gt; &#91;\n                'User-Agent' =&gt; 'acme-app\/1.0 (+https:\/\/example.com)',\n                'Accept'     =&gt; 'application\/json',\n            ],\n        ]);\n\n        return new self($http, $credentials);\n    }\n\n    \/**\n     * @param array&lt;string, scalar&gt; $params\n     * @return array&lt;string, mixed&gt;\n     *\/\n    public function post(string $path, array $params): array\n    {\n        $payload = $this-&gt;credentials-&gt;applyToBody($params);\n        $payload&#91;'output'] = 'json';\n\n        try {\n            $response = $this-&gt;http-&gt;post($path, &#91;\n                RequestOptions::FORM_PARAMS =&gt; $payload,\n                RequestOptions::HEADERS     =&gt; $this-&gt;credentials-&gt;applyToHeaders(&#91;]),\n            ]);\n        } catch (ConnectException $e) {\n            throw new SmsTransportException('Could not reach the SMS gateway.', 0, $e);\n        } catch (RequestException $e) {\n            throw new SmsTransportException('SMS gateway request failed.', 0, $e);\n        }\n\n        $status = $response-&gt;getStatusCode();\n        $body   = (string) $response-&gt;getBody();\n\n        if ($status &gt;= 500) {\n            throw new SmsTransportException(\"Gateway returned HTTP {$status}.\");\n        }\n\n        try {\n            \/** @var array&lt;string, mixed&gt; $decoded *\/\n            $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);\n        } catch (\\JsonException $e) {\n            throw new SmsTransportException('Gateway returned a non-JSON body.', 0, $e);\n        }\n\n        return $decoded;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two design decisions worth explaining.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code><a href=\"https:\/\/docs.guzzlephp.org\/en\/stable\/request-options.html\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">HTTP_ERRORS<\/a><\/code> is set to <code>false<\/code>. 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 <code>statusCode<\/code> and <code>reason<\/code> that you need in order to decide whether to retry. Throwing before you parse the body discards the diagnosis. Handle the status code yourself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.php-fig.org\/psr\/psr-18\/\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">Transport failures<\/a> and application failures get different exception types. A <code>ConnectException<\/code> means the message definitely was not accepted and is safe to retry. A parsed response with <code>status: error<\/code> 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Send Method<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>public function send(OutboundMessage $message): SendResult\n{\n    $params = &#91;\n        'sendMethod'     =&gt; 'quick',\n        'mobile'         =&gt; $message-&gt;msisdn,\n        'msg'            =&gt; $message-&gt;text,\n        'senderid'       =&gt; $message-&gt;senderId,\n        'msgType'        =&gt; $message-&gt;isUnicode ? 'unicode' : 'text',\n        'duplicatecheck' =&gt; 'true',\n    ];\n\n    if ($message-&gt;dltEntityId !== null) {\n        $params&#91;'dltEntityId']   = $message-&gt;dltEntityId;\n        $params&#91;'dltTemplateId'] = $message-&gt;dltTemplateId;\n    }\n\n    $raw = $this-&gt;post('SMSApi\/send', $params);\n\n    return SendResult::fromArray($raw);\n}\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>final class SendResult\n{\n    private function __construct(\n        public readonly bool $accepted,\n        public readonly ?string $transactionId,\n        public readonly string $statusCode,\n        public readonly string $reason,\n        public readonly string $invalidMobile,\n    ) {\n    }\n\n    \/** @param array&lt;string, mixed&gt; $raw *\/\n    public static function fromArray(array $raw): self\n    {\n        return new self(\n            accepted:      ($raw&#91;'status'] ?? '') === 'success',\n            transactionId: isset($raw&#91;'transactionId']) ? (string) $raw&#91;'transactionId'] : null,\n            statusCode:    (string) ($raw&#91;'statusCode'] ?? '0'),\n            reason:        (string) ($raw&#91;'reason'] ?? 'unknown'),\n            invalidMobile: (string) ($raw&#91;'invalidMobile'] ?? ''),\n        );\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>invalidMobile<\/code>. 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">7. Authentication: userid and password versus apiKey<\/h2>\n\n\n\n<a id=\"authentication\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">The API supports two authentication methods and you should pick deliberately.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><\/th><th><code>userid<\/code> + <code>password<\/code><\/th><th><code>apiKey<\/code> HTTP header<\/th><\/tr><\/thead><tbody><tr><td>Where it travels<\/td><td>In the request body or query string<\/td><td>In an HTTP header<\/td><\/tr><tr><td>Log exposure risk<\/td><td>High if sent via GET, moderate via POST<\/td><td>Low, headers are rarely logged by default<\/td><\/tr><tr><td>Rotation<\/td><td>Changing it changes your panel login too<\/td><td>Rotate independently of the account password<\/td><\/tr><tr><td>Revocation blast radius<\/td><td>Revoking locks you out of the panel<\/td><td>Revoke one key, other integrations keep working<\/td><\/tr><tr><td>Per-integration separation<\/td><td>Not possible, one credential<\/td><td>One key per service is possible<\/td><\/tr><tr><td>Best for<\/td><td>Quick tests, single-script use<\/td><td>Anything running in production<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Use the API key in production. Create and manage keys through the endpoints listed under Create API Key in the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">developer API sidebar<\/a>. 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nfinal class SmsCredentials\n{\n    private function __construct(\n        private readonly ?string $apiKey,\n        private readonly ?string $userId,\n        private readonly ?string $password,\n    ) {\n    }\n\n    public static function fromApiKey(string $apiKey): self\n    {\n        return new self($apiKey, null, null);\n    }\n\n    public static function fromBasic(string $userId, string $password): self\n    {\n        return new self(null, $userId, $password);\n    }\n\n    \/**\n     * @param array&lt;string, scalar&gt; $params\n     * @return array&lt;string, scalar&gt;\n     *\/\n    public function applyToBody(array $params): array\n    {\n        if ($this-&gt;apiKey !== null) {\n            return $params;\n        }\n\n        $params&#91;'userid']   = (string) $this-&gt;userId;\n        $params&#91;'password'] = (string) $this-&gt;password;\n\n        return $params;\n    }\n\n    \/**\n     * @param array&lt;string, string&gt; $headers\n     * @return array&lt;string, string&gt;\n     *\/\n    public function applyToHeaders(array $headers): array\n    {\n        if ($this-&gt;apiKey !== null) {\n            $headers&#91;'apikey'] = $this-&gt;apiKey;\n        }\n\n        return $headers;\n    }\n\n    public function __debugInfo(): array\n    {\n        return &#91;'auth' =&gt; $this-&gt;apiKey !== null ? 'apiKey(redacted)' : 'basic(redacted)'];\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That <code>__debugInfo<\/code> override is small and worth copying. It means <code>var_dump<\/code>, Xdebug and most error reporters print <code>apiKey(redacted)<\/code> instead of your live credential when this object appears in a stack trace.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">8. Sending to Many Recipients Without Melting Your Web Server<\/h2>\n\n\n\n<a id=\"sending\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">You have three options, and the right one depends on volume and personalisation.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Approach<\/th><th><code>sendMethod<\/code><\/th><th>Personalised text<\/th><th>Best volume range<\/th><th>Trade-off<\/th><\/tr><\/thead><tbody><tr><td>Comma-separated recipients in one call<\/td><td><code>quick<\/code><\/td><td>No, one body for all<\/td><td>Tens to low hundreds<\/td><td>Simple, but one failure response covers many numbers<\/td><\/tr><tr><td>Saved group<\/td><td><code>group<\/code><\/td><td>No<\/td><td>Recurring sends to a stable list<\/td><td>List lives on the platform, not in your database<\/td><\/tr><tr><td>File upload<\/td><td><code>file<\/code><\/td><td>Depends on file format<\/td><td>Large one-off campaigns<\/td><td>Asynchronous, needs a separate status check<\/td><\/tr><tr><td>One call per recipient from a queue<\/td><td><code>quick<\/code><\/td><td>Yes<\/td><td>Any, with concurrency control<\/td><td>Most HTTP calls, but full per-message control<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a concurrency-controlled sender using Guzzle&#8217;s pool, which is the right tool when you genuinely need throughput from PHP and do not have a queue worker fleet.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Pool;\nuse GuzzleHttp\\Psr7\\Request;\nuse Psr\\Http\\Message\\ResponseInterface;\n\n\/**\n * @param list&lt;array{mobile: string, msg: string}&gt; $messages\n * @return array&lt;string, string&gt; mobile =&gt; transactionId or error reason\n *\/\nfunction sendConcurrently(Client $http, array $messages, string $apiKey, string $senderId, int $concurrency = 10): array\n{\n    $results = &#91;];\n\n    $requests = static function () use ($messages, $apiKey, $senderId): \\Generator {\n        foreach ($messages as $index =&gt; $m) {\n            $body = http_build_query(&#91;\n                'sendMethod' =&gt; 'quick',\n                'mobile'     =&gt; $m&#91;'mobile'],\n                'msg'        =&gt; $m&#91;'msg'],\n                'senderid'   =&gt; $senderId,\n                'msgType'    =&gt; 'text',\n                'output'     =&gt; 'json',\n            ]);\n\n            yield $index =&gt; new Request(\n                'POST',\n                'https:\/\/unify.smsgateway.center\/SMSApi\/send',\n                &#91;\n                    'apikey'       =&gt; $apiKey,\n                    'Content-Type' =&gt; 'application\/x-www-form-urlencoded',\n                ],\n                $body\n            );\n        }\n    };\n\n    $pool = new Pool($http, $requests(), &#91;\n        'concurrency' =&gt; $concurrency,\n        'fulfilled'   =&gt; static function (ResponseInterface $response, int $index) use (&amp;$results, $messages): void {\n            $decoded = json_decode((string) $response-&gt;getBody(), true);\n            $results&#91;$messages&#91;$index]&#91;'mobile']] = $decoded&#91;'status'] === 'success'\n                ? (string) $decoded&#91;'transactionId']\n                : 'ERROR: ' . ($decoded&#91;'reason'] ?? 'unknown');\n        },\n        'rejected'    =&gt; static function (\\Throwable $e, int $index) use (&amp;$results, $messages): void {\n            $results&#91;$messages&#91;$index]&#91;'mobile']] = 'TRANSPORT_FAILURE: ' . $e-&gt;getMessage();\n        },\n    ]);\n\n    $pool-&gt;promise()-&gt;wait();\n\n    return $results;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Set <code>concurrency<\/code> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/smpp-rate-limits-throughput-tuning\/\">SMPP rate limits and throughput tuning<\/a> before you do.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Message Length and Encoding<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A GSM-7 SMS holds 160 characters. Add one character outside the <a href=\"https:\/\/en.wikipedia.org\/wiki\/GSM_03.38\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">GSM-7 alphabet<\/a>, 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 <code>strlen<\/code> counts bytes and <code>mb_strlen<\/code> counts characters, and neither counts SMS segments, because GSM-7 has extension characters (<code>{<\/code>, <code>}<\/code>, <code>[<\/code>, <code>]<\/code>, <code>~<\/code>, <code>^<\/code>, <code>\\<\/code>, <code>|<\/code>, <code>\u20ac<\/code>) that consume two septets each.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nfinal class GsmSegmentCounter\n{\n    private const GSM7_BASIC = \"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\\\"#\u00a4%&amp;'()*+,-.\/0123456789:;&lt;=&gt;?\"\n        . \"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\";\n\n    private const GSM7_EXTENDED = \"^{}\\\\&#91;~]|\u20ac\";\n\n    public static function isGsm7(string $text): bool\n    {\n        foreach (preg_split('\/\/u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {\n            if (!str_contains(self::GSM7_BASIC, $char) &amp;&amp; !str_contains(self::GSM7_EXTENDED, $char)) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    \/** @return array{encoding: string, units: int, segments: int} *\/\n    public static function measure(string $text): array\n    {\n        if (!self::isGsm7($text)) {\n            $units = mb_strlen($text, 'UTF-8');\n            $segments = $units &lt;= 70 ? 1 : (int) ceil($units \/ 67);\n\n            return &#91;'encoding' =&gt; 'UCS-2', 'units' =&gt; $units, 'segments' =&gt; max(1, $segments)];\n        }\n\n        $units = 0;\n        foreach (preg_split('\/\/u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {\n            $units += str_contains(self::GSM7_EXTENDED, $char) ? 2 : 1;\n        }\n\n        $segments = $units &lt;= 160 ? 1 : (int) ceil($units \/ 153);\n\n        return &#91;'encoding' =&gt; 'GSM-7', 'units' =&gt; $units, 'segments' =&gt; max(1, $segments)];\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-length-calculator\/\">SMS length calculator<\/a> for one-off checks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When the message is genuinely non-Latin, set <code>msgType<\/code> to <code>unicode<\/code> rather than <code>text<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">9. India Specific: DLT Entity IDs, Template IDs and Pre-Send Validation<\/h2>\n\n\n\n<a id=\"india\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Under <a href=\"https:\/\/www.trai.gov.in\/\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">TRAI<\/a>&#8216;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&#8217;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 <a href=\"https:\/\/www.smsgatewaycenter.com\/pe-tm-binding-chain-process-in-dlt\/\">PE-TM binding chain explainer<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In practice this means two extra parameters on every India-bound send:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$params&#91;'dltEntityId']   = '1201159xxxxxxxxxxx';\n$params&#91;'dltTemplateId'] = '1707162xxxxxxxxxxx';\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Validate Before You Send<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The mistake most teams make is discovering the mismatch from a delivery report hours later. There is a pre-flight endpoint for exactly this: <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/validateTemplate<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">Validate Template<\/a>. It takes your <code>senderid<\/code>, the fully rendered <code>msg<\/code> and the <code>dltTemplateId<\/code>, and tells you whether the operator will accept it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nfinal class DltValidator\n{\n    public function __construct(private readonly SmsGatewayClient $client) {}\n\n    public function isValid(string $senderId, string $renderedMessage, string $dltTemplateId): bool\n    {\n        $raw = $this-&gt;client-&gt;post('SMSApi\/validateTemplate', &#91;\n            'senderid'      =&gt; $senderId,\n            'msg'           =&gt; $renderedMessage,\n            'dltTemplateId' =&gt; $dltTemplateId,\n        ]);\n\n        if (($raw&#91;'status'] ?? '') === 'success') {\n            return true;\n        }\n\n        \/\/ 188 is the documented template mismatch code.\n        if ((string) ($raw&#91;'statusCode'] ?? '') === '188') {\n            throw new DltTemplateMismatchException(\n                sprintf(\n                    'Rendered message does not match DLT template %s. Gateway said: %s',\n                    $dltTemplateId,\n                    (string) ($raw&#91;'reason'] ?? 'message template mismatch')\n                )\n            );\n        }\n\n        throw new SmsApiException((string) ($raw&#91;'reason'] ?? 'Template validation failed.'));\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wire this into your CI pipeline, not just your runtime. Render every template in your codebase with representative sample values, call <code>validateTemplate<\/code> 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rendering Templates Correctly<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DLT variable syntax is <code>{#var#}<\/code>. The registered template might read:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Dear {#var#}, your order {#var#} has been shipped and will arrive by {#var#}. Team ACME\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The corresponding PHP renderer must substitute positionally and must not alter anything else, including trailing whitespace and punctuation.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nfinal class DltTemplateRenderer\n{\n    private const PLACEHOLDER = '{#var#}';\n\n    \/** @param list&lt;string&gt; $values *\/\n    public function render(string $template, array $values): string\n    {\n        $expected = substr_count($template, self::PLACEHOLDER);\n\n        if ($expected !== count($values)) {\n            throw new \\InvalidArgumentException(\n                sprintf('Template expects %d variables, %d given.', $expected, count($values))\n            );\n        }\n\n        $result = $template;\n        foreach ($values as $value) {\n            $pos = strpos($result, self::PLACEHOLDER);\n            if ($pos === false) {\n                break;\n            }\n            $result = substr_replace($result, $value, $pos, strlen(self::PLACEHOLDER));\n        }\n\n        return $result;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>substr_replace<\/code> in a loop rather than <code>str_replace<\/code>, because <code>str_replace<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Never call <code>trim()<\/code>, <code>ucfirst()<\/code>, <code>htmlspecialchars()<\/code> 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Creating Templates Programmatically<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you manage many templates, <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/template\/create<\/code> accepts a <code>message<\/code> parameter containing the template content and submits it for approval. See <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-message-template\/\">Create Message Template<\/a>. 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">10. OTP in PHP: Generate and Verify Without Rolling Your Own<\/h2>\n\n\n\n<a id=\"otp\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">This is the section most PHP developers need and most tutorials skip. The instinct is to generate a six digit code with <code>random_int(100000, 999999)<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The API exposes a dedicated OTP endpoint at <code>https:\/\/unify.smsgateway.center\/SMSApi\/otp<\/code> which handles generation, delivery, expiry and verification server side.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Generate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Per the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">Generate OTP documentation<\/a>, you send <code>sendMethod=generate<\/code> along with <code>mobile<\/code>, <code>msgType<\/code>, <code>msg<\/code> (containing the literal <code>$otp$<\/code> placeholder, which the platform substitutes), <code>medium=sms<\/code>, <code>codeType<\/code> (<code>num<\/code>, <code>alpha<\/code> or <code>alphanum<\/code>), <code>codeExpiry<\/code> in seconds (default 300), <code>codeLength<\/code>, <code>senderid<\/code> and <code>output<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nfinal class OtpService\n{\n    public function __construct(\n        private readonly SmsGatewayClient $client,\n        private readonly string $senderId,\n    ) {\n    }\n\n    \/** @return array{createTime: string, expiryTime: string, retryAfter: string} *\/\n    public function generate(string $msisdn, string $messageTemplate, int $length = 6, int $expirySeconds = 300): array\n    {\n        $raw = $this-&gt;client-&gt;post('SMSApi\/otp', &#91;\n            'sendMethod' =&gt; 'generate',\n            'mobile'     =&gt; $msisdn,\n            'msgType'    =&gt; 'text',\n            'msg'        =&gt; $messageTemplate, \/\/ must contain the literal $otp$ placeholder\n            'medium'     =&gt; 'sms',\n            'codeType'   =&gt; 'num',\n            'codeExpiry' =&gt; $expirySeconds,\n            'codeLength' =&gt; $length,\n            'senderid'   =&gt; $this-&gt;senderId,\n        ]);\n\n        if (($raw&#91;'status'] ?? '') !== 'success') {\n            throw new SmsApiException((string) ($raw&#91;'reason'] ?? 'OTP generation failed.'));\n        }\n\n        return &#91;\n            'createTime' =&gt; (string) $raw&#91;'createTime'],\n            'expiryTime' =&gt; (string) $raw&#91;'expiryTime'],\n            'retryAfter' =&gt; (string) $raw&#91;'retryAfter'],\n        ];\n    }\n\n    public function verify(string $msisdn, string $submittedCode): bool\n    {\n        $raw = $this-&gt;client-&gt;post('SMSApi\/otp', &#91;\n            'sendMethod' =&gt; 'verify',\n            'mobile'     =&gt; $msisdn,\n            'otp'        =&gt; $submittedCode,\n        ]);\n\n        return ($raw&#91;'status'] ?? '') === 'success';\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful generate returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n    \"status\": \"success\",\n    \"mobile\": \"91777xxxxxxx\",\n    \"transactionId\": \"0\",\n    \"statusCode\": \"300\",\n    \"type\": \"new\",\n    \"reason\": \"OTP successfully generated.\",\n    \"createTime\": \"1700117933163\",\n    \"expiryTime\": \"1700118233163\",\n    \"retryAfter\": \"1700117993163\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful verify returns <code>statusCode<\/code> <code>301<\/code> with the reason <code>OTP has been Verified Successfully.<\/code><\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Use retryAfter, Do Not Invent Your Own Resend Timer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>retryAfter<\/code> 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 &#8220;Resend&#8221; button that is enabled and then fails.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$otp = $otpService-&gt;generate($msisdn, $template);\n\n$resendAvailableAt = (int) round(((int) $otp&#91;'retryAfter']) \/ 1000);\n$secondsUntilResend = max(0, $resendAvailableAt - time());\n\nreturn response()-&gt;json(&#91;\n    'expires_in'      =&gt; max(0, ((int) round(((int) $otp&#91;'expiryTime']) \/ 1000)) - time()),\n    'resend_in'       =&gt; $secondsUntilResend,\n]);\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">OTP Is the One Case for a Synchronous Send<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public function sendLoginOtp(string $msisdn): array\n{\n    if ($this-&gt;breaker-&gt;isOpen()) {\n        throw new OtpChannelUnavailableException('SMS channel temporarily unavailable.');\n    }\n\n    try {\n        return $this-&gt;otpService-&gt;generate($msisdn, $this-&gt;template);\n    } catch (SmsTransportException $e) {\n        $this-&gt;breaker-&gt;recordFailure();\n        throw new OtpChannelUnavailableException('Could not send the code, please try again.', 0, $e);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-vs-sms-api-2025\/\">SMS API vs WhatsApp Business API<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">11. Handling Delivery Reports in PHP<\/h2>\n\n\n\n<a id=\"handling\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">An HTTP 200 with <code>status: success<\/code> 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 &#8220;but I sent it&#8221; support ticket lives.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/handling-delivery-reports-php.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/handling-delivery-reports-php-1024x584.webp\" alt=\"Illustration about Handling Delivery Reports in PHP\" class=\"wp-image-2597\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/handling-delivery-reports-php-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/handling-delivery-reports-php-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/handling-delivery-reports-php-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/handling-delivery-reports-php.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">There are two ways to close the gap, and mature integrations use both.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><\/th><th>Polling <code>SMSApi\/reports\/status<\/code><\/th><th>Webhook push<\/th><\/tr><\/thead><tbody><tr><td>Latency to know<\/td><td>Poll interval, typically minutes<\/td><td>Near real time<\/td><\/tr><tr><td>Infrastructure needed<\/td><td>A cron and an outbound connection<\/td><td>A public HTTPS endpoint you must keep up<\/td><\/tr><tr><td>Behaviour during your outage<\/td><td>Nothing lost, you catch up on next poll<\/td><td>Depends on retry policy, gaps are possible<\/td><\/tr><tr><td>Cost at scale<\/td><td>More requests, mostly returning nothing new<\/td><td>One request per state change<\/td><\/tr><tr><td>Best used as<\/td><td>Reconciliation backstop<\/td><td>Primary signal<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Polling for Delivery Reports<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The DLR endpoint is <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">SMS Delivery Report<\/a>. It takes <code>fromdate<\/code>, <code>todate<\/code>, <code>pageLimit<\/code>, an optional <code>mobileNo<\/code> or <code>uuId<\/code>, and an optional <code>startCursor<\/code> for pagination.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nfinal class DeliveryReportPoller\n{\n    public function __construct(private readonly SmsGatewayClient $client) {}\n\n    \/**\n     * @return \\Generator&lt;int, array&lt;string, mixed&gt;&gt;\n     *\/\n    public function fetch(\\DateTimeImmutable $from, \\DateTimeImmutable $to, int $pageLimit = 100): \\Generator\n    {\n        $cursor = null;\n\n        do {\n            $params = &#91;\n                'method'    =&gt; 'getDlr',\n                'fromdate'  =&gt; $from-&gt;format('Y-m-d'),\n                'todate'    =&gt; $to-&gt;format('Y-m-d'),\n                'pageLimit' =&gt; $pageLimit,\n            ];\n\n            if ($cursor !== null) {\n                $params&#91;'startCursor'] = $cursor;\n            }\n\n            $raw  = $this-&gt;client-&gt;post('SMSApi\/reports\/status', $params);\n            $rows = $raw&#91;'reports_dlrList'] ?? &#91;];\n\n            if ($rows === &#91;]) {\n                return;\n            }\n\n            foreach ($rows as $row) {\n                yield $row;\n            }\n\n            $last   = $rows&#91;array_key_last($rows)];\n            $cursor = $last&#91;'cursorId'] ?? null;\n        } while ($cursor !== null &amp;&amp; count($rows) === $pageLimit);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each row in <code>reports_dlrList<\/code> carries <code>country<\/code>, <code>amount<\/code>, <code>msgType<\/code>, <code>cost<\/code>, <code>deliveryTime<\/code>, <code>length<\/code>, <code>channel<\/code>, <code>msgId<\/code>, <code>cause<\/code>, <code>mobileNo<\/code>, <code>uuId<\/code>, <code>dltTemplateId<\/code>, <code>globalErrorCode<\/code>, <code>cursorId<\/code>, <code>network<\/code>, <code>senderName<\/code>, <code>flashMsg<\/code>, <code>submitTime<\/code>, <code>text<\/code> and <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Persist <code>cost<\/code> and <code>length<\/code> alongside the delivery status. Reconciling billed segments against what your <code>GsmSegmentCounter<\/code> 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Receiving Webhooks<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Register a callback URL with <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code>, passing <code>smswebhook<\/code> (your URL) and <code>smswebhookrate<\/code> (the DLR throughput you want, default 10), documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-webhook\/\">Create Webhook<\/a>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$client-&gt;post('SMSApi\/webhook\/create', &#91;\n    'smswebhook'     =&gt; 'https:\/\/api.example.com\/webhooks\/sms-dlr',\n    'smswebhookrate' =&gt; 10,\n]);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful registration returns:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"webhook\",\n    \"action\": \"create\",\n    \"status\": \"success\",\n    \"msg\": \"Webhook added successfully.\",\n    \"code\": \"200\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Set <code>smswebhookrate<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\n\/\/ public\/webhooks\/sms-dlr.php\n\/\/ Front controller pattern: accept, enqueue, acknowledge. Nothing else.\n\nrequire __DIR__ . '\/..\/..\/vendor\/autoload.php';\n\n$raw = file_get_contents('php:\/\/input') ?: '';\n\n\/\/ Cheap shared-secret check in the query string, over HTTPS only.\nif (!hash_equals(getenv('SGC_WEBHOOK_SECRET') ?: '', $_GET&#91;'token'] ?? '')) {\n    http_response_code(403);\n    exit;\n}\n\n\/\/ Persist the raw payload verbatim and return immediately.\n\/\/ A worker parses it out of band.\n$queue = new \\App\\Queue\\RedisQueue();\n$queue-&gt;push('sms.dlr.received', &#91;\n    'received_at' =&gt; microtime(true),\n    'remote_addr' =&gt; $_SERVER&#91;'REMOTE_ADDR'] ?? null,\n    'content_type'=&gt; $_SERVER&#91;'CONTENT_TYPE'] ?? null,\n    'raw'         =&gt; $raw,\n    'query'       =&gt; $_GET,\n]);\n\nhttp_response_code(200);\nheader('Content-Type: application\/json');\necho '{\"ok\":true}';\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-delivery-reports-dlr\/\">delivery report knowledge base article<\/a> and the individual status explainers such as <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-in_retry-retry-attempted-mean\/\">IN_RETRY<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-absent_sub-absent-subscriber-unreachable-mean\/\">ABSENT_SUB<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">12. Error Handling, Retries and Idempotency<\/h2>\n\n\n\n<a id=\"error\"><\/a>\n\n\n\n<h3 class=\"wp-block-heading\">Classify Before You Retry<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every failure falls into one of three buckets, and each bucket gets different handling.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Class<\/th><th>Examples<\/th><th>Retry?<\/th><th>Backoff<\/th><th>Action<\/th><\/tr><\/thead><tbody><tr><td>Transport<\/td><td>DNS failure, connection refused, TLS handshake failure, read timeout<\/td><td>Yes<\/td><td>Exponential with jitter<\/td><td>Retry up to a bounded attempt count<\/td><\/tr><tr><td>Transient application<\/td><td>Gateway 5xx, throttling, temporary operator unavailability<\/td><td>Yes<\/td><td>Exponential with jitter, longer base<\/td><td>Retry, then park for manual review<\/td><\/tr><tr><td>Permanent<\/td><td>Invalid sender ID, DLT template mismatch, malformed recipient, insufficient balance, blocked sender<\/td><td>No<\/td><td>Not applicable<\/td><td>Fail fast, alert, do not consume retry budget<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The authoritative lists live at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API Response Error Codes<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">Delivery Error Codes<\/a>, and both are also fetchable programmatically via <code>POST SMSApi\/info\/responsecodes<\/code> and <code>POST SMSApi\/info\/deliverycodes<\/code>. Pull them at deploy time and cache them, rather than hardcoding a list that goes stale.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nenum FailureClass\n{\n    case Transport;\n    case TransientApplication;\n    case Permanent;\n}\n\nfinal class FailureClassifier\n{\n    \/** @var list&lt;string&gt; Codes known to be permanent for this integration. *\/\n    private const PERMANENT_CODES = &#91;'188']; \/\/ template mismatch\n\n    public function classify(?\\Throwable $exception, ?SendResult $result): FailureClass\n    {\n        if ($exception instanceof SmsTransportException) {\n            return FailureClass::Transport;\n        }\n\n        if ($result === null) {\n            return FailureClass::Transport;\n        }\n\n        if (in_array($result-&gt;statusCode, self::PERMANENT_CODES, true)) {\n            return FailureClass::Permanent;\n        }\n\n        \/\/ Anything the gateway explicitly rejected with a business reason is\n        \/\/ permanent unless it appears in the transient allowlist you build\n        \/\/ from the published response-code list.\n        return $result-&gt;accepted ? FailureClass::TransientApplication : FailureClass::Permanent;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Keep <code>PERMANENT_CODES<\/code> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API retry strategy: handling failed messages<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Exponential Backoff With Jitter<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nfunction backoffDelaySeconds(int $attempt, int $baseSeconds = 2, int $capSeconds = 300): int\n{\n    $exponential = min($capSeconds, $baseSeconds * (2 ** ($attempt - 1)));\n\n    \/\/ Full jitter. Without this, a gateway blip synchronises every one of your\n    \/\/ workers to retry at the same instant and you re-create the outage.\n    return random_int(0, $exponential);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Idempotency<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The gateway&#8217;s <code>duplicatecheck<\/code> parameter helps, but you should not depend on a platform-side window you do not control. Own it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Sms;\n\nuse Psr\\SimpleCache\\CacheInterface;\n\nfinal class IdempotentSender\n{\n    private const TTL_SECONDS = 86_400;\n\n    public function __construct(\n        private readonly SmsGatewayClient $client,\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    public function send(OutboundMessage $message, string $businessKey): SendResult\n    {\n        \/\/ The key must be derived from the business event, not from a random\n        \/\/ value generated inside the retry loop, or every retry gets a new key.\n        $key = 'sms:idem:' . hash('sha256', $businessKey . '|' . $message-&gt;msisdn . '|' . $message-&gt;text);\n\n        \/** @var array&lt;string, mixed&gt;|null $cached *\/\n        $cached = $this-&gt;cache-&gt;get($key);\n        if ($cached !== null) {\n            return SendResult::fromArray($cached);\n        }\n\n        \/\/ Claim the key before sending so a concurrent worker cannot duplicate.\n        $this-&gt;cache-&gt;set($key . ':lock', 1, 120);\n\n        $result = $this-&gt;client-&gt;send($message);\n\n        if ($result-&gt;accepted) {\n            $this-&gt;cache-&gt;set($key, &#91;\n                'status'        =&gt; 'success',\n                'transactionId' =&gt; $result-&gt;transactionId,\n                'statusCode'    =&gt; $result-&gt;statusCode,\n                'reason'        =&gt; $result-&gt;reason,\n            ], self::TTL_SECONDS);\n        }\n\n        return $result;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The critical detail is where <code>$businessKey<\/code> comes from. It must be stable across retries: an order ID, an invoice number, a login session identifier. <code>uniqid()<\/code> generated inside the send method defeats the entire mechanism, because each retry produces a different key.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">13. Laravel Integration<\/h2>\n\n\n\n<a id=\"laravel\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">Laravel supplies the queue, the retry semantics and the configuration layer, so most of the plumbing above collapses into framework idioms.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Configuration<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\/\/ config\/sms.php\n\nreturn &#91;\n    'base_uri'   =&gt; env('SGC_BASE_URI', 'https:\/\/unify.smsgateway.center\/'),\n    'api_key'    =&gt; env('SGC_API_KEY'),\n    'userid'     =&gt; env('SGC_USERID'),\n    'password'   =&gt; env('SGC_PASSWORD'),\n    'sender_id'  =&gt; env('SGC_SENDER_ID'),\n    'dlt' =&gt; &#91;\n        'enabled'   =&gt; (bool) env('SGC_DLT_ENABLED', true),\n        'entity_id' =&gt; env('SGC_DLT_ENTITY_ID'),\n    ],\n    'timeouts' =&gt; &#91;\n        'connect' =&gt; (float) env('SGC_CONNECT_TIMEOUT', 5.0),\n        'total'   =&gt; (float) env('SGC_TIMEOUT', 15.0),\n    ],\n];\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Never call <code>env()<\/code> outside a config file in Laravel. Once <code>php artisan config:cache<\/code> runs in production, <code>env()<\/code> returns <code>null<\/code> everywhere except within config files, and your integration silently authenticates with empty credentials.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Service Provider<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Sms\\SmsCredentials;\nuse App\\Sms\\SmsGatewayClient;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\RequestOptions;\nuse Illuminate\\Support\\ServiceProvider;\n\nfinal class SmsServiceProvider extends ServiceProvider\n{\n    public function register(): void\n    {\n        $this-&gt;app-&gt;singleton(SmsGatewayClient::class, function ($app) {\n            $config = $app&#91;'config']&#91;'sms'];\n\n            $credentials = $config&#91;'api_key']\n                ? SmsCredentials::fromApiKey($config&#91;'api_key'])\n                : SmsCredentials::fromBasic($config&#91;'userid'], $config&#91;'password']);\n\n            $http = new Client(&#91;\n                'base_uri'                      =&gt; $config&#91;'base_uri'],\n                RequestOptions::CONNECT_TIMEOUT =&gt; $config&#91;'timeouts']&#91;'connect'],\n                RequestOptions::TIMEOUT         =&gt; $config&#91;'timeouts']&#91;'total'],\n                RequestOptions::HTTP_ERRORS     =&gt; false,\n            ]);\n\n            return new SmsGatewayClient($http, $credentials);\n        });\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">The Queued Job<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace App\\Jobs;\n\nuse App\\Sms\\FailureClass;\nuse App\\Sms\\FailureClassifier;\nuse App\\Sms\\OutboundMessage;\nuse App\\Sms\\SmsGatewayClient;\nuse App\\Sms\\SmsTransportException;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\n\nfinal class SendSmsJob implements ShouldQueue, ShouldBeUnique\n{\n    use Dispatchable;\n    use InteractsWithQueue;\n    use Queueable;\n    use SerializesModels;\n\n    public int $tries = 5;\n    public int $timeout = 30;\n    public int $uniqueFor = 3600;\n\n    public function __construct(\n        private readonly OutboundMessage $message,\n        private readonly string $businessKey,\n    ) {\n    }\n\n    public function uniqueId(): string\n    {\n        return $this-&gt;businessKey . ':' . $this-&gt;message-&gt;msisdn;\n    }\n\n    \/** @return list&lt;int&gt; Full-jitter backoff, in seconds, per attempt. *\/\n    public function backoff(): array\n    {\n        return &#91;random_int(1, 4), random_int(2, 15), random_int(5, 60), random_int(15, 180)];\n    }\n\n    public function handle(SmsGatewayClient $client, FailureClassifier $classifier): void\n    {\n        try {\n            $result = $client-&gt;send($this-&gt;message);\n        } catch (SmsTransportException $e) {\n            \/\/ Let the queue retry. Transport failures are retryable by definition.\n            throw $e;\n        }\n\n        if ($result-&gt;accepted) {\n            SmsTransaction::record($this-&gt;businessKey, $this-&gt;message-&gt;msisdn, $result-&gt;transactionId);\n            return;\n        }\n\n        if ($classifier-&gt;classify(null, $result) === FailureClass::Permanent) {\n            \/\/ Do not burn retries on a message that will never be accepted.\n            $this-&gt;fail(new \\RuntimeException(\n                \"Permanent SMS failure {$result-&gt;statusCode}: {$result-&gt;reason}\"\n            ));\n            return;\n        }\n\n        throw new \\RuntimeException(\"Transient SMS failure {$result-&gt;statusCode}: {$result-&gt;reason}\");\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ShouldBeUnique<\/code> combined with a <code>uniqueId<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>$this-&gt;fail()<\/code> 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Dispatching<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>SendSmsJob::dispatch(\n    new OutboundMessage(\n        msisdn: $order-&gt;customer-&gt;msisdn,\n        text: $renderer-&gt;render($template-&gt;body, &#91;$order-&gt;customer-&gt;first_name, (string) $order-&gt;id, $order-&gt;eta-&gt;format('d M')]),\n        senderId: config('sms.sender_id'),\n        dltEntityId: config('sms.dlt.entity_id'),\n        dltTemplateId: $template-&gt;dlt_template_id,\n    ),\n    businessKey: 'order.shipped:' . $order-&gt;id,\n)-&gt;onQueue('sms');\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Put SMS on its own queue with its own workers. Sharing a queue with report generation means a slow report blocks a shipping notification.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">14. Security: Credentials, Input Validation and Log Hygiene<\/h2>\n\n\n\n<a id=\"security\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Credentials belong in the environment, never in the repository.<\/strong> That is the well-known part. The less well-known part is that <code>.env<\/code> files get read by more things than you expect. Make sure your webroot is <code>public\/<\/code> and not the project root, and confirm it directly by requesting <code>\/.env<\/code> against your staging deployment. Finding it returns a 200 is a bad way to learn this.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Validate and <a href=\"https:\/\/github.com\/giggsey\/libphonenumber-for-php\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">normalise recipient numbers<\/a> before they reach the gateway.<\/strong> User-supplied phone numbers arrive with spaces, hyphens, leading zeros, <code>+<\/code> prefixes and occasionally letters. Normalise to E.164 without the plus, and reject anything that does not parse.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nuse libphonenumber\\PhoneNumberFormat;\nuse libphonenumber\\PhoneNumberUtil;\n\nfunction normaliseMsisdn(string $input, string $defaultRegion = 'IN'): string\n{\n    $util = PhoneNumberUtil::getInstance();\n\n    try {\n        $parsed = $util-&gt;parse($input, $defaultRegion);\n    } catch (\\libphonenumber\\NumberParseException $e) {\n        throw new \\InvalidArgumentException(\"Unparseable phone number: {$input}\", 0, $e);\n    }\n\n    if (!$util-&gt;isValidNumber($parsed)) {\n        throw new \\InvalidArgumentException(\"Invalid phone number: {$input}\");\n    }\n\n    \/\/ E.164 without the leading plus, which is what the API expects.\n    return ltrim($util-&gt;format($parsed, PhoneNumberFormat::E164), '+');\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Install with <code>composer require giggsey\/libphonenumber-for-php<\/code>. Regex-based validation of international numbers is a well-documented dead end and the numbering plans change more often than your regex will.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Never interpolate unvalidated user input into a message body.<\/strong> An attacker who controls part of an SMS body controls a message that arrives under your brand&#8217;s sender ID. Whitelist which fields a template accepts and cap their length.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Redact credentials and message bodies from logs.<\/strong> Message bodies contain OTPs, order values and names. Under most data protection regimes they are personal data.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nuse Monolog\\LogRecord;\nuse Monolog\\Processor\\ProcessorInterface;\n\nfinal class SmsLogRedactor implements ProcessorInterface\n{\n    private const SENSITIVE = &#91;'password', 'apikey', 'apiKey', 'otp', 'msg'];\n\n    public function __invoke(LogRecord $record): LogRecord\n    {\n        $context = $record-&gt;context;\n\n        foreach (self::SENSITIVE as $key) {\n            if (isset($context&#91;$key])) {\n                $context&#91;$key] = '&#91;redacted]';\n            }\n        }\n\n        if (isset($context&#91;'mobile']) &amp;&amp; is_string($context&#91;'mobile'])) {\n            $context&#91;'mobile'] = substr($context&#91;'mobile'], 0, 4) . str_repeat('*', max(0, strlen($context&#91;'mobile']) - 6)) . substr($context&#91;'mobile'], -2);\n        }\n\n        return $record-&gt;with(context: $context);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Verify webhook authenticity.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rate limit outbound sends per recipient.<\/strong> An unbounded &#8220;resend code&#8221; 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&#8217;s <code>retryAfter<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">15. Testing Your Integration Without Sending Real SMS<\/h2>\n\n\n\n<a id=\"testing\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\">You cannot write meaningful tests against an integration that charges you per assertion. Guzzle&#8217;s <code>MockHandler<\/code> solves this.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Sms;\n\nuse App\\Sms\\OutboundMessage;\nuse App\\Sms\\SmsCredentials;\nuse App\\Sms\\SmsGatewayClient;\nuse App\\Sms\\SmsTransportException;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Exception\\ConnectException;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Middleware;\nuse GuzzleHttp\\Psr7\\Request;\nuse GuzzleHttp\\Psr7\\Response;\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class SmsGatewayClientTest extends TestCase\n{\n    \/** @var list&lt;array{request: \\Psr\\Http\\Message\\RequestInterface}&gt; *\/\n    private array $history = &#91;];\n\n    private function clientFor(MockHandler $mock): SmsGatewayClient\n    {\n        $stack = HandlerStack::create($mock);\n        $stack-&gt;push(Middleware::history($this-&gt;history));\n\n        return new SmsGatewayClient(\n            new Client(&#91;'handler' =&gt; $stack, 'http_errors' =&gt; false]),\n            SmsCredentials::fromApiKey('test-key'),\n        );\n    }\n\n    public function testSuccessfulSendReturnsTransactionId(): void\n    {\n        $client = $this-&gt;clientFor(new MockHandler(&#91;\n            new Response(200, &#91;], json_encode(&#91;\n                'status'        =&gt; 'success',\n                'mobile'        =&gt; '919999999999',\n                'invalidMobile' =&gt; '',\n                'transactionId' =&gt; 'txn-123',\n                'statusCode'    =&gt; '200',\n                'reason'        =&gt; 'success',\n            ], JSON_THROW_ON_ERROR)),\n        ]));\n\n        $result = $client-&gt;send(new OutboundMessage(\n            msisdn: '919999999999',\n            text: 'Hello',\n            senderId: 'SMSGAT',\n        ));\n\n        self::assertTrue($result-&gt;accepted);\n        self::assertSame('txn-123', $result-&gt;transactionId);\n    }\n\n    public function testApiKeyIsSentAsHeaderAndNotInBody(): void\n    {\n        $client = $this-&gt;clientFor(new MockHandler(&#91;\n            new Response(200, &#91;], '{\"status\":\"success\",\"statusCode\":\"200\",\"reason\":\"success\"}'),\n        ]));\n\n        $client-&gt;send(new OutboundMessage('919999999999', 'Hello', 'SMSGAT'));\n\n        $request = $this-&gt;history&#91;0]&#91;'request'];\n\n        self::assertSame('test-key', $request-&gt;getHeaderLine('apikey'));\n        self::assertStringNotContainsString('password', (string) $request-&gt;getBody());\n    }\n\n    public function testTemplateMismatchIsSurfacedAsPermanentFailure(): void\n    {\n        $client = $this-&gt;clientFor(new MockHandler(&#91;\n            new Response(200, &#91;], json_encode(&#91;\n                'status'     =&gt; 'error',\n                'statusCode' =&gt; '188',\n                'reason'     =&gt; 'message template mismatch',\n            ], JSON_THROW_ON_ERROR)),\n        ]));\n\n        $result = $client-&gt;send(new OutboundMessage('919999999999', 'Wrong text', 'SMSGAT'));\n\n        self::assertFalse($result-&gt;accepted);\n        self::assertSame('188', $result-&gt;statusCode);\n    }\n\n    public function testConnectionFailureBecomesTransportException(): void\n    {\n        $client = $this-&gt;clientFor(new MockHandler(&#91;\n            new ConnectException('Connection refused', new Request('POST', 'SMSApi\/send')),\n        ]));\n\n        $this-&gt;expectException(SmsTransportException::class);\n\n        $client-&gt;send(new OutboundMessage('919999999999', 'Hello', 'SMSGAT'));\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add a DLT rendering test that asserts the rendered output is byte-identical to the registered template with variables substituted, and run <code>validateTemplate<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">16. Performance and Scaling Notes<\/h2>\n\n\n\n<a id=\"performance\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reuse the HTTP client.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Enable connection reuse in workers.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Watch worker memory.<\/strong> Long-running Laravel workers accumulate memory. Use <code>--max-jobs<\/code> and <code>--max-time<\/code> so workers cycle before they bloat, and set <code>--memory<\/code> below your container limit so the worker exits cleanly rather than being killed mid-send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Know when HTTP is the wrong protocol.<\/strong> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/smpp-connectivity\/\">SMPP connectivity documentation<\/a>, with tuning guidance in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/smpp-rate-limits-throughput-tuning\/\">SMPP rate limits and throughput tuning<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instrument four numbers.<\/strong> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">17. Production Readiness Checklist<\/h2>\n\n\n\n<a id=\"production\"><\/a>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Item<\/th><th>Why it matters<\/th><th>Done<\/th><\/tr><\/thead><tbody><tr><td>Sends happen off the request thread<\/td><td>A slow gateway cannot exhaust your FPM pool<\/td><td>\u2610<\/td><\/tr><tr><td>Connect and total timeouts set explicitly<\/td><td>cURL&#8217;s default is effectively infinite<\/td><td>\u2610<\/td><\/tr><tr><td>API key auth, not userid and password<\/td><td>Rotatable, revocable, header-only<\/td><td>\u2610<\/td><\/tr><tr><td>Credentials in environment, <code>.env<\/code> outside webroot<\/td><td>Confirmed by requesting <code>\/.env<\/code> on staging<\/td><td>\u2610<\/td><\/tr><tr><td>Recipient numbers normalised to E.164 with libphonenumber<\/td><td>Regex validation of international numbers fails<\/td><td>\u2610<\/td><\/tr><tr><td>Segment count and encoding computed before send<\/td><td>Catches accidental UCS-2 cost doubling<\/td><td>\u2610<\/td><\/tr><tr><td>DLT template validated in CI via <code>validateTemplate<\/code><\/td><td>Catches drift at merge, not in production<\/td><td>\u2610<\/td><\/tr><tr><td>Idempotency key derived from a stable business event<\/td><td>Prevents duplicate sends on lost responses<\/td><td>\u2610<\/td><\/tr><tr><td>Failures classified before retrying<\/td><td>Stops retry storms on permanent errors<\/td><td>\u2610<\/td><\/tr><tr><td>Backoff includes full jitter<\/td><td>Prevents thundering herd on recovery<\/td><td>\u2610<\/td><\/tr><tr><td><code>transactionId<\/code> persisted against the business record<\/td><td>Without it you cannot correlate a DLR<\/td><td>\u2610<\/td><\/tr><tr><td>DLR ingestion running, webhook or polling or both<\/td><td>HTTP 200 is not delivery<\/td><td>\u2610<\/td><\/tr><tr><td>Webhook receiver acknowledges before processing<\/td><td>Prevents backpressure and gateway timeouts<\/td><td>\u2610<\/td><\/tr><tr><td>Webhook receiver is idempotent<\/td><td>Duplicate deliveries are normal<\/td><td>\u2610<\/td><\/tr><tr><td>Message bodies and credentials redacted from logs<\/td><td>OTPs and personal data in logs are a breach<\/td><td>\u2610<\/td><\/tr><tr><td>Per-recipient send rate limit<\/td><td>Blocks SMS pumping through resend buttons<\/td><td>\u2610<\/td><\/tr><tr><td>Alert on acceptance rate drop and delivered rate drop<\/td><td>Silent failure is the expensive failure<\/td><td>\u2610<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Ready to send your first message?<\/strong> 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. <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">Get started<\/a> or browse the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">full API reference<\/a>.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">18. Common Mistakes and How to Fix Them<\/h2>\n\n\n\n<a id=\"common\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Treating HTTP 200 as delivered.<\/strong> It means accepted. Persist the <code>transactionId<\/code> and reconcile against a DLR before you tell a user their code was sent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Building the query string by concatenation.<\/strong> A password containing <code>&amp;<\/code> or <code>+<\/code> breaks silently and looks exactly like an authentication failure. Use <code>http_build_query<\/code> or Guzzle&#8217;s <code>form_params<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Retrying permanent failures.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Generating the idempotency key inside the retry loop.<\/strong> Every retry gets a new key and the mechanism does nothing. Derive it from the business event upstream of the send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Calling <code>trim()<\/code> on a rendered DLT template.<\/strong> Any normalisation applied after rendering and before sending can produce a mismatch with what you registered. Render, validate, send, unchanged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Registering a webhook rate your endpoint cannot serve.<\/strong> <code>smswebhookrate<\/code> is a promise you are making about your own capacity. Start at the default and raise it after you have measured.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Using <code>strlen<\/code> to check message length.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Parsing webhook payloads inline in the receiver.<\/strong> Store raw, acknowledge, parse in a worker. An unexpected format becomes a replayable record instead of a lost report.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Calling <code>env()<\/code> outside config files in Laravel.<\/strong> After <code>config:cache<\/code>, it returns null and your integration authenticates with nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Logging the full request payload at debug level in production.<\/strong> It works fine until an auditor reads the log and finds every OTP your system has ever sent.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">19. FAQs<\/h2>\n\n\n\n<a id=\"faqs\"><\/a>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send an SMS in PHP?<\/strong> POST to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> using cURL or Guzzle with form-encoded parameters: authentication (<code>userid<\/code> and <code>password<\/code>, or an <code>apikey<\/code> header), <code>sendMethod=quick<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>senderid<\/code>, <code>msgType=text<\/code> and <code>output=json<\/code>. Parse the JSON response and store the returned <code>transactionId<\/code>. A complete working example is in section 5.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need Composer to integrate an SMS API in PHP?<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use cURL or Guzzle?<\/strong> 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 <code>Pool<\/code>, and can be mocked in unit tests without hitting the network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between <code>userid<\/code> plus <code>password<\/code> and <code>apiKey<\/code> authentication?<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why did my SMS return success but never arrive?<\/strong> 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 <code>SMSApi\/reports\/status<\/code> and read the <code>cause<\/code> and <code>globalErrorCode<\/code> fields.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle DLT templates in PHP?<\/strong> Store the registered template text and its <code>dltTemplateId<\/code> together, render variables positionally into the <code>{#var#}<\/code> placeholders without applying any other string transformation, validate the rendered output via <code>POST SMSApi\/validateTemplate<\/code> before sending, and pass <code>dltEntityId<\/code> and <code>dltTemplateId<\/code> on the send call. Section 9 has the full renderer and validator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does status code 188 mean?<\/strong> It is the documented template mismatch response from the validation endpoint, returned as <code>{\"status\":\"error\",\"statusCode\":\"188\",\"reason\":\"message template mismatch\"}<\/code>. Your rendered message does not match the registered DLT template. Compare them character by character, including trailing whitespace and punctuation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send OTP SMS in PHP?<\/strong> Use the dedicated OTP endpoint at <code>https:\/\/unify.smsgateway.center\/SMSApi\/otp<\/code> with <code>sendMethod=generate<\/code>, supplying <code>mobile<\/code>, <code>msg<\/code> containing the literal <code>$otp$<\/code> placeholder, <code>codeLength<\/code>, <code>codeExpiry<\/code>, <code>codeType<\/code> and <code>senderid<\/code>. Verify with the same endpoint using <code>sendMethod=verify<\/code> and the submitted <code>otp<\/code>. This delegates generation, expiry and verification to the platform instead of you implementing them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should OTP sends be queued like other messages?<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I receive delivery reports in PHP?<\/strong> Two options. Poll <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code> and paginate using <code>startCursor<\/code> and <code>cursorId<\/code>. Or register a callback with <code>POST SMSApi\/webhook\/create<\/code> supplying <code>smswebhook<\/code> and <code>smswebhookrate<\/code>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I avoid sending duplicate SMS on a retry?<\/strong> 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&#8217;s <code>duplicatecheck<\/code> parameter is a useful second layer but should not be your only defence. Section 12 has a working implementation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many characters can one SMS hold?<\/strong> 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 <code>\u20ac<\/code> and curly braces, consume two units each.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test an SMS integration without sending real messages?<\/strong> Inject Guzzle&#8217;s <code>MockHandler<\/code> in place of the real handler and return canned responses for the success, template mismatch, transport failure and partial batch cases. Add <code>Middleware::history<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use this from Laravel, Symfony or WordPress?<\/strong> 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&#8217;s queue. In WordPress, register the send as an Action Scheduler task rather than calling it during a page load.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>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.<\/p>\n","protected":false},"author":118,"featured_media":2599,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2051,2055,2052,2053,2054,2049,2050,2048,2047,2056],"class_list":["post-2594","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-dlt-template-php","tag-guzzle-sms-api","tag-laravel-sms","tag-otp-sms-php","tag-php-curl-sms-api","tag-php-sms-gateway","tag-php-sms-integration","tag-send-sms-php","tag-sms-api-php","tag-sms-delivery-report-php"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2594","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2594"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2594\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2599"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2594"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2594"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2594"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}