Search by

ahmedlaggoun / taqnyat

ahmed-laggoun

SOLID, layered Taqnyat SMS integration for Laravel.

Package info

github.com/ahmed-laggoun/taqnyat

pkg:composer/ahmedlaggoun/taqnyat

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.2 2026-08-22 07:44 UTC

This package is auto-updated.

Last update: 2026-08-22 07:46:34 UTC


README

A SOLID, layered Laravel integration for the Taqnyat SMS API — sending, scheduling, cancellation, balance, sender names, platform status and delivery callbacks, behind one small application-facing class.

Latest Version on Packagist Tests Total Downloads License

Why this package

Taqnyat's API has a number of sharp edges — a 201 response can describe failed recipients, accepted arrives as a broken pseudo-JSON string, cost is renamed to balance on one endpoint, and errors are free-text English with no codes. This package absorbs all of that so your application code stays boring.

  • Typed value objects instead of arrays. DispatchReport, AccountBalance, SenderName, SystemStatus, and an integer-based Amount that never touches a float.
  • Partial failure is visible. hasRejections() on every send, because a successful HTTP response can still contain rejected numbers.
  • Three drivers. taqnyat for live sends, log for local development, array for tests — all satisfying the same contract, no "not supported" throws.
  • Safe by default. Token in the Authorization header (never a query string), no automatic retry of non-idempotent sends, message bodies and phone numbers redacted from logs.
  • Automatic chunking above Taqnyat's 1000-recipient limit, with results aggregated into one report.
  • Local segment estimation (GSM-7 vs UCS-2) so you can check campaign cost before committing to it.
  • Framework-free domain layer, so the SMS logic is testable without booting Laravel and a second provider can be added without touching your app code.

Requirements

PHP 8.2+
Laravel 10, 11, 12
Extensions ext-json, ext-mbstring

Installation

composer require ahmedlaggoun/taqnyat

The service provider is registered automatically via package discovery. Publish the config file if you want to edit it:

php artisan vendor:publish --tag=taqnyat-config

Then add your credentials to .env:

SMS_DRIVER=log
TAQNYAT_TOKEN=your-token
TAQNYAT_SENDER=YourBrand

Start on SMS_DRIVER=log — nothing is sent, and every message is written to the log with the body redacted. Switch to SMS_DRIVER=taqnyat once the wiring is confirmed.

Note: only sending is switched by the driver. Balance, sender names, scheduling and status always talk to the real API — a log driver that pretended to know your balance would be worse than useless.

Usage

Resolve the entry point from the container, or type-hint it anywhere Laravel injects dependencies:

use AhmedLaggoun\Taqnyat\Application\Taqnyat;

public function __construct(private readonly Taqnyat $sms) {}

Sending

$report = $this->sms->text('966500000000', 'Your code is 4821');

// Partial failure is normal and silent — always check.
if ($report->hasRejections()) {
    logger()->warning('Some recipients were rejected', [
        'rejected' => count($report->rejected()),
    ]);
}

$report->accepted();     // ['966500000000']
$report->messageIds();   // ['1234567']
$report->totalCost();    // Amount
$report->fullyAccepted();

Multiple recipients, and a sender name overriding the configured default:

$this->sms->text(
    ['966500000000', '966500000001'],
    'Maintenance window tonight at 22:00.',
    from: 'MyBrand',
);

Recipients are normalised for you: +966…, 00966…, spaces, dashes and parentheses are all accepted and cleaned, duplicates are removed, and anything that is not a valid international MSISDN throws InvalidMessageException before a request is made.

Building a message explicitly

SmsMessage is the validated value object behind text(). Build one directly when you need to inspect it first:

use AhmedLaggoun\Taqnyat\Domain\Data\SmsMessage;

$message = SmsMessage::make($recipients, $body, 'MyBrand');

$message->encoding();               // MessageEncoding::Gsm7 | Ucs2
$message->estimatedSegments();      // billable segments per recipient
$message->estimatedTotalSegments(); // × recipient count

$report = $this->sms->send($message);

Scheduling and cancelling

$report = $this->sms->scheduleText('966500000000', 'Reminder', now()->addDay());

// Store these. There is no endpoint that lists scheduled messages, so an id
// you lose is a message you can only cancel through the portal.
$order->update(['sms_delete_ids' => $report->deleteIds()]);

$this->sms->cancel($order->sms_delete_ids[0]);

Account, senders and status

$balance = $this->sms->balance();

$balance->balance->format();   // "2044.0000 SAR"
$balance->isActive();
$balance->hasExpired();

$this->sms->balanceIsBelow('50.00');   // decimal string, never a float

$this->sms->senders();                 // Collection<SenderName>
$this->sms->senderIsUsable('MyBrand'); // case-sensitive, like the API

$this->sms->isOperational();           // needs no credentials

senderIsUsable() is worth calling before a campaign: Taqnyat matches sender names case-sensitively and rejects the entire send with a generic message on a mismatch.

Queue your sends

class SendVerificationCode implements ShouldQueue
{
    public function handle(Taqnyat $sms): void
    {
        $sms->text($this->phone, "Your code is {$this->code}");
    }
}

Taqnyat documents no rate limit or SLA, and a slow upstream should not slow your response.

Error handling

Every exception implements TaqnyatException, so one catch block covers the package:

use AhmedLaggoun\Taqnyat\Domain\Exceptions\ApiException;
use AhmedLaggoun\Taqnyat\Domain\Exceptions\AuthenticationException;
use AhmedLaggoun\Taqnyat\Domain\Exceptions\InsufficientBalanceException;
use AhmedLaggoun\Taqnyat\Domain\Exceptions\InvalidMessageException;
use AhmedLaggoun\Taqnyat\Domain\Exceptions\TransportException;

try {
    $this->sms->text($phone, $body);
} catch (InvalidMessageException $e) {
    // Bad recipient, empty body, invalid sender — never reached the network.
} catch (InsufficientBalanceException $e) {
    // Top up. Alert an operator; retrying will not help.
} catch (AuthenticationException $e) {
    // Bad or restricted token.
} catch (ApiException $e) {
    $e->reason;        // FailureReason enum
    $e->rawMessage;    // Taqnyat's original text, for support tickets
    $e->httpStatus;

    if ($e->reason->needsOperatorAttention()) { /* page someone */ }
    if ($e->isTransient()) { /* safe-ish to try again later */ }
} catch (TransportException $e) {
    // Network failure, timeout, unreadable response.
}

Taqnyat returns failures as free text with no error codes, so FailureReason classifies by substring match and falls back to Unknown rather than guessing — always include a default arm when branching on it.

Reason Meaning
NoBalance, InsufficientBalance Out of credit
SenderNotAccepted, SenderNotActive, SenderExpired, SenderMissing Sender name problem
RecipientsInvalid, TooManyRecipients, BodyMissing Malformed request
AccountSuspended, ApiSendingDisabled Account disabled
IpNotAuthorised, CountryNotAuthorised, ProxyRejected Portal security restriction
InvalidCredentials, InvalidDeleteId, MethodNotAllowed Request rejected
UpstreamUnavailable, NoData, Unknown Transient or unclassified

Configuration

All keys can be set from .env; publish the config file only if you need to change something without an env variable.

Env variable Default Description
SMS_DRIVER taqnyat taqnyat, log, or array / null
TAQNYAT_TOKEN Bearer token, sent as a header
TAQNYAT_SENDER Default sender name
TAQNYAT_BASE_URL https://api.taqnyat.sa API base URL
TAQNYAT_MAX_RECIPIENTS 1000 Chunk size for large sends
TAQNYAT_DELETE_ENDPOINT /v1/messages/delete See vendor notes below
TAQNYAT_TIMEOUT 15 Request timeout, seconds
TAQNYAT_CONNECT_TIMEOUT 5 Connect timeout, seconds
TAQNYAT_RETRY_TIMES 3 Retries — applies to GET only
TAQNYAT_RETRY_SLEEP_MS 400 Delay between retries
TAQNYAT_LOG_CHANNEL app default Log channel for this package
TAQNYAT_CALLBACK_PATH webhooks/taqnyat Callback path prefix
TAQNYAT_CALLBACK_PATH_TOKEN Unguessable segment; route is off while empty
TAQNYAT_CALLBACK_PASS_PHRASE Phrase configured in the Taqnyat portal
TAQNYAT_CALLBACK_IPS Optional comma-separated IP allowlist

Testing your application

Swap in the in-memory driver and assert on intent, no HTTP faking required:

use AhmedLaggoun\Taqnyat\Domain\Contracts\SmsSender;
use AhmedLaggoun\Taqnyat\Infrastructure\Gateways\ArrayGateway;

app()->instance(SmsSender::class, $fake = new ArrayGateway);

$this->post('/register', [...]);

expect($fake->wasSent(fn ($m) => $m->recipients === ['966500000000']))->toBeTrue();
expect($fake->sent())->toHaveCount(1);

Or set SMS_DRIVER=array in phpunit.xml to apply it suite-wide.

Because the send path is behind the narrow SmsSender interface, a class that only sends can type-hint SmsSender instead of Taqnyat and be doubled with a single method.

Delivery callbacks

Read this before enabling callbacks.

Taqnyat's callback authenticates in one direction only. You echo a configured pass phrase back so they know delivery succeeded — they retry three times and then give up. Nothing in the incoming request proves it came from Taqnyat: no signature, no shared secret in the payload, no documented source range. Their documentation does not even specify the payload's shape.

So the pass phrase authenticates your response to them. It does not authenticate their request to you.

TAQNYAT_CALLBACK_PATH_TOKEN=<40 random chars, e.g. Str::random(40)>
TAQNYAT_CALLBACK_PASS_PHRASE=<the phrase set in the portal>
TAQNYAT_CALLBACK_IPS=  # optional, comma separated

The route is not registered at all until TAQNYAT_CALLBACK_PATH_TOKEN is set, because an unguessable path is the only access control available. Setting the token without a pass phrase throws at boot rather than silently failing every acknowledgement.

Register the resulting URL — https://your-app.test/webhooks/taqnyat/{token} — in the Taqnyat portal, then listen for the event:

use AhmedLaggoun\Taqnyat\Application\Events\DeliveryCallbackReceived;

Event::listen(DeliveryCallbackReceived::class, function (DeliveryCallbackReceived $event) {
    Message::where('provider_id', $event->callback->messageId)
        ->update(['delivered_at' => $event->callback->looksDelivered() ? now() : null]);
});

Correlate on a messageId your own send recorded, and do not let a callback alone drive anything irreversible. Unlike a payment webhook there is no lookup endpoint to confirm against, so this is a status hint, not a verified fact.

The controller returns the phrase as a plain-text body, which is the least assuming reading of a spec that only says the phrase must come back. If their end rejects it, wrap it in JSON — but confirm with a real callback rather than guessing, since a rejected acknowledgement costs three retries and then silence.

Security notes

  • The token goes in the header, never the URL. Taqnyat's own HTTP examples append ?bearerTokens=…, which lands in web server access logs, reverse proxies, APM traces and browser history. Their curl examples and OpenAPI spec use the Authorization header, and so does this package. Treat any token already used in a query string as burned and regenerate it.
  • Lock the token down in the portal. Under Developers → Security Settings you can restrict it to your server IPs and to permitted destination countries. That is the main thing limiting damage if it leaks — do it before going live.
  • Sends are never retried automatically. GET retries; POST and DELETE do not. There is no idempotency key and no way to look up a message afterwards, so a transparent retry of a send that actually succeeded bills and delivers twice, undetectably.
  • Bodies and recipients are never logged verbatim. Bodies routinely carry OTPs; the redactor replaces them with a length and masks MSISDNs.
  • Delete ids are generated with high entropy. The vendor example uses 100. A scheduled message is cancelled by id alone, so a low or sequential id risks cancelling the wrong message.
  • Validate recipients against your own allowlist for internal tooling. An authenticated endpoint with a free-text destination is a billing-drain primitive; SmsMessage enforces format, not authorisation.

Notes on the vendor documentation

The prose docs and the OpenAPI spec disagree in several places. All of it is handled in code, but it is worth knowing what you are working with:

  • accepted and rejected are strings, not arrays. Taqnyat sends "[966500000000,]" — square brackets, unquoted values, trailing comma. json_decode returns null for it. Parsed with a dedicated reader.
  • A 201 can describe failures. Rejected recipients are reported inside a successful response, so "no exception" does not mean "all delivered".
  • messageId and cost change type between sources — integer and bare number in the prose docs, strings in the OpenAPI spec. Both accepted.
  • The scheduled-send response renames cost to balance. Same field, different key, in the vendor's own two examples. Both read.
  • Success status codes are inconsistent: send returns 201, balance returns 200 in the docs but 201 in the spec, and a GET for senders returns 201. The body also carries its own statusCode, which can report a failure under an HTTP 200 — so the body is checked, not just the HTTP status.
  • The delete endpoint has two documented paths. Prose and OpenAPI say /v1/messages/delete; the curl example says /v1/messages. The majority wins by default, overridable via TAQNYAT_DELETE_ENDPOINT.
  • Errors are free-text English with no codes. FailureReason classifies by substring match — including their typo "expierd" — and falls back to Unknown rather than guessing. The raw string is kept on the exception.
  • Degraded-system status is 400 in the docs and 503 in the spec. Neither is trusted: a system counts as operational only when it reports 200 and names no affected service.
  • accountExpiryDate is d-m-Y ("23-08-2021"), which strtotime reads as month-first. Parsed with an explicit format.
  • Maximum 1000 recipients per request. Larger sends are split and the results aggregated by DispatchReport.
  • There is no message-status lookup endpoint. Once a message is sent you cannot ask what happened to it — which is why the callback cannot be verified the way a payment webhook can.

Architecture

config/taqnyat.php                        configuration + env keys
routes/taqnyat.php                        callback route

src/Domain/                               framework-free core
  Contracts/                              SmsSender, MessageScheduler,
                                          AccountInspector,
                                          SenderNameProvider,
                                          SystemStatusProvider
  Data/                                   Amount, SmsMessage, DispatchResult,
                                          DispatchReport, AccountBalance,
                                          SenderName, SystemStatus,
                                          DeliveryCallback
  Enums/                                  MessageEncoding, FailureReason,
                                          SenderStatus, SenderDestination
  Exceptions/

src/Application/                          use cases
  Taqnyat.php                             the entry point your code calls
  Events/DeliveryCallbackReceived.php

src/Infrastructure/                       everything touching the outside
  Contracts/TaqnyatTransport.php          the HTTP port
  Gateways/TaqnyatGateway.php             vendor adapter
  Gateways/LogGateway.php                 local driver
  Gateways/ArrayGateway.php               test driver
  Http/HttpTransport.php                  wire protocol, asymmetric retry
  Http/Controllers/                       callback endpoint
  Providers/TaqnyatServiceProvider.php    container wiring

src/Support/                              shared kernel: pure helpers

Dependencies point inward, and it is verified rather than aspirational: Domain imports nothing from Application or Infrastructure, Application imports nothing from Infrastructure, and only Infrastructure knows Taqnyat exists.

One honest caveat: Domain is free of the Laravel framework but uses Illuminate\Support\Collection as a return type, which ships in the standalone illuminate/collections. It runs outside Laravel; it is not zero-dependency.

SRPHttpTransport moves JSON; TaqnyatGateway knows payload shapes; DTOs own parsing and validation; Taqnyat is the application surface. OCP — a new provider implements the contracts; nothing above changes. LSPLogGateway and ArrayGateway satisfy SmsSender fully, no "not supported" throws. ISP — five narrow interfaces, so code that only sends type-hints SmsSender and gets a one-method double. DIP — the concrete gateway is named in exactly one place, the service provider.

Adding other providers

The five contracts in src/Domain/Contracts/ are the extension points. A new provider is a new class in Infrastructure/Gateways/ plus one arm in the service provider's match. Nothing in your application changes.

FailureReason currently encodes Taqnyat's error vocabulary. If you add a second provider, treat it as your own vocabulary and map each vendor's strings inside its adapter.

Contributing

Issues and pull requests are welcome. Before opening a PR:

composer format    # Pint
composer analyse   # PHPStan level 6
composer test      # Pest
composer check     # all three

The suite uses a fake transport, so no HTTP fakes are needed. It covers the vendor's parsing quirks specifically: pseudo-array recipient strings, the cost/balance rename, mixed types on messageId, partial rejection, d-m-Y expiry parsing, chunking above 1000, and the error-string typos.

Security vulnerabilities

Please report security issues privately via GitHub Security Advisories rather than the public issue tracker.

Changelog

See CHANGELOG.md. This project follows Semantic Versioning; a breaking change to any interface in src/Domain/Contracts/ is a major bump.

Credits

This package is not affiliated with or endorsed by Taqnyat.

License

MIT. See LICENSE.