ahmedlaggoun / taqnyat
SOLID, layered Taqnyat SMS integration for Laravel.
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- psr/log: ^2.0|^3.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- phpstan/phpstan: ^1.11
README
Taqnyat SMS integration for Laravel, in the same layered shape as my other provider packages. PHP 8.2+, Laravel 10/11/12.
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.
SRP — HttpTransport 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.
LSP — LogGateway 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:
acceptedandrejectedare strings, not arrays. Taqnyat sends"[966500000000,]"— square brackets, unquoted values, trailing comma.json_decodereturnsnullfor 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. messageIdandcostchange type between sources — integer and bare number in the prose docs, strings in the OpenAPI spec. Both accepted.- The scheduled-send response renames
costtobalance. 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 viaTAQNYAT_DELETE_ENDPOINT. - Errors are free-text English with no codes.
FailureReasonclassifies by substring match — including their typo "expierd" — and falls back toUnknownrather 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.
accountExpiryDateisd-m-Y("23-08-2021"), whichstrtotimereads 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 theAuthorizationheader, 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()anddelete()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;
SmsMessageenforces 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.