ahmedlaggoun/taqnyat

SOLID, layered Taqnyat SMS integration for Laravel.

Maintainers

Package info

github.com/ahmed-laggoun/Taqnyat

pkg:composer/ahmedlaggoun/taqnyat

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-19 21:45 UTC

This package is auto-updated.

Last update: 2026-08-19 21:49:30 UTC


README

Taqnyat SMS integration for Laravel, in the same layered shape as my other provider packages. PHP 8.2+, Laravel 10/11/12.

tests

Install

composer require ahmedlaggoun/taqnyat
php artisan vendor:publish --tag=taqnyat-config
SMS_DRIVER=log
TAQNYAT_TOKEN=
TAQNYAT_SENDER=

Package discovery registers the provider. Start on SMS_DRIVER=log — nothing is sent and everything is logged with bodies redacted — then switch to taqnyat once the wiring is confirmed.

Usage

use AhmedLaggoun\Taqnyat\Application\Taqnyat;
use AhmedLaggoun\Taqnyat\Domain\Data\SmsMessage;

$sms = app(Taqnyat::class);

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

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

// Scheduling — store the delete ids, they are the only way to cancel
$report = $sms->scheduleText('966500000000', 'Reminder', now()->addDay());
$order->update(['sms_delete_ids' => $report->deleteIds()]);

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

// Account and senders
$sms->balance()->balance->format();       // "2044.0000 SAR"
$sms->balanceIsBelow('50.00');
$sms->senderIsUsable('MyBrand');
$sms->isOperational();                     // no credentials needed

Send from a queued job rather than the request cycle. Taqnyat documents no rate limit or SLA, and a slow upstream should not slow your response.

Structure

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.

The same honest caveat as the other packages: 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.

Notes on the vendor documentation

The prose docs and the OpenAPI spec disagree in several places. Handled in code, but worth knowing:

  • 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". Check hasRejections() on every send.
  • 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.

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, not after.
  • 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.

Callbacks — read before enabling

Taqnyat's callback authenticates in one direction only. You echo a configured pass phrase back so they know delivery succeeded, retrying three times and then giving 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 — the schema shown in the callback section is a copy-paste of the sender-names response.

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

TAQNYAT_CALLBACK_PATH_TOKEN=<40 random chars>
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.

Event::listen(DeliveryCallbackReceived::class, function ($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.

Testing

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();

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

composer test      # Pest
composer analyse   # PHPStan level 6
composer format    # Pint

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 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.

Changelog

See CHANGELOG.md. Semantic versioning: a breaking change to any interface in src/Domain/Contracts/ is a major bump.

License

MIT. See LICENSE.