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
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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.
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-basedAmountthat never touches a float. - Partial failure is visible.
hasRejections()on every send, because a successful HTTP response can still contain rejected numbers. - Three drivers.
taqnyatfor live sends,logfor local development,arrayfor tests — all satisfying the same contract, no "not supported" throws. - Safe by default. Token in the
Authorizationheader (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
logdriver 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 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.
- Sends are never retried automatically.
GETretries;POSTandDELETEdo 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.
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:
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".
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.
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.
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.
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.