ahmedlaggoun / smsala
SOLID, driver-based SMS layer for Laravel with an SMSala gateway.
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.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
Send SMS through SMSala from Laravel, with typed responses, delivery-status webhooks, and drivers that keep your test suite off the network.
$sms = app(SmsService::class); $sms->otp('918744815388', 'Your code is 978823');
- Typed everything — enums for message type, encoding and delivery status; immutable DTOs for every response shape. No array digging.
- Validated before it costs you — recipients, sender IDs and callback URLs are checked locally, so a malformed send fails before it is billed.
- Accurate cost estimates — GSM-7 septets, escape characters and emoji are all counted the way the carrier counts them.
- Safe by default — TLS enforced, tokens and OTP bodies kept out of logs, sends never retried into a double charge.
- Three drivers —
smsalafor production,logfor local development,arrayfor tests.
Requirements
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 10, 11, 12 |
| Extensions | json, mbstring |
Installation
composer require ahmedlaggoun/smsala
The service provider is registered automatically. Publish the config file if you want to edit it directly:
php artisan vendor:publish --tag=smsala-config
Add your credentials to .env:
SMS_DRIVER=log SMSALA_API_TOKEN=your-api-token SMSALA_SENDER_ID=YOURBRAND
Start on SMS_DRIVER=log. Nothing is sent and every message is written to your
log channel, so you can confirm the wiring before spending anything. Switch to
SMS_DRIVER=smsala when you are ready to send for real.
Sending
Inject SmsService or resolve it from the container:
use AhmedLaggoun\SMSAla\Application\SmsService; class RegisterController { public function __construct(private SmsService $sms) {} public function store(Request $request) { $this->sms->otp($request->phone, "Your code is {$code}"); } }
The three message types
Carriers route these differently, with different throughput and different rules about when they may be delivered. Sending promotional traffic as transactional is a good way to get a sender ID blocked.
$sms->otp('918744815388', 'Your code is 978823'); // one-time passcodes $sms->text('918744815388', 'Your order has shipped'); // transactional $sms->promotional($subscribers, '20% off this weekend'); // marketing
Multiple recipients
Pass any iterable, or a comma-separated string. Duplicates are removed, so a doubled row in an imported CSV is billed once.
$results = $sms->text(['918744815388', '917006129382'], 'Hello'); $results = $sms->text(User::pluck('phone'), 'Hello');
Numbers are normalised: spaces, dashes, brackets and a leading + are
stripped, and what remains must be 8–15 digits including the country code.
Full control
SmsMessage::make() validates everything at construction, so an invalid
message throws before any request is made.
use AhmedLaggoun\SMSAla\Domain\Data\SmsMessage; use AhmedLaggoun\SMSAla\Domain\Enums\MessageType; $sms->send(SmsMessage::make( recipients: ['918744815388', '917006129382'], text: 'Hello', type: MessageType::Promotional, sender: 'YOURBRAND', // overrides SMSALA_SENDER_ID callbackUrl: route('sms.callback'), // overrides SMSALA_CALLBACK_URL reference: (string) Str::uuid(), // your own correlation id ));
Reading the results
You get one SmsResult per recipient. Partial success is normal — some
recipients can be accepted while others are rejected in the same call — so
check each result rather than assuming the batch succeeded as a unit.
foreach ($results as $result) { if ($result->accepted()) { SentMessage::create([ 'provider_id' => $result->messageId, 'phone' => $result->destinationAddress, ]); } else { Log::warning('SMS rejected', $result->toArray()); } }
Match results to recipients on $result->destinationAddress, not on array
position. If the gateway returns no row for a recipient, you get an explicit
failed result for it rather than a silent gap.
accepted() means the gateway took the message — not that it reached a
handset. Final delivery arrives later, via a webhook or a DLR lookup.
Send from a queue
The API documents no rate limit or SLA. A slow gateway should not slow your response:
class SendOrderSms implements ShouldQueue { public function __construct(private string $phone, private string $body) {} public function handle(SmsService $sms): void { $sms->text($this->phone, $this->body); } }
As a notification channel
The package does not ship a channel, because a good one is four lines and depends on how your app stores phone numbers:
class SmsalaChannel { public function __construct(private SmsService $sms) {} public function send(object $notifiable, Notification $notification): void { $this->sms->text($notifiable->routeNotificationFor('smsala'), $notification->toSmsala($notifiable)); } }
Estimating cost before you send
An SMS is billed in parts, and parts are not characters. estimateParts()
counts the way the carrier does:
- GSM-7 fits 160 characters — but
^ { } \ [ ] ~ |and€cost two each. - Unicode (any non-ASCII text) fits 70 — and emoji cost two each.
- Concatenated messages carry a header charged to every part, dropping the limits to 153 and 67.
$message = SmsMessage::make($recipients, $body); $message->estimatedParts(); // parts per recipient $message->estimatedSegments(); // parts x recipients — what you will be billed if ($message->estimatedSegments() > 500) { throw new CampaignNeedsApproval($message->estimatedSegments()); }
Encoding is chosen automatically — ASCII stays ASCII, anything else becomes
UCS-2 — or you can pass encoding: explicitly.
Delivery reports
Webhooks (recommended)
SMSala posts delivery updates to a URL you supply. The route registers itself only when you configure a token, so the endpoint does not exist in projects that do not use it.
Generate a token:
php artisan tinker >>> Str::random(40)
SMSALA_CALLBACK_TOKEN=1sSHtCTdmRsjWt41Lbcn8HcbHXujMcJUpvGyRgQC SMSALA_CALLBACK_URL=https://yourapp.com/webhooks/sms/1sSHtCTdmRsjWt41Lbcn8HcbHXujMcJUpvGyRgQC
The token becomes the URL path segment, and must be 32–128 characters of
A–Z a–z 0–9 _ -. Both variables must end with the same token. Then listen for
the event:
use AhmedLaggoun\SMSAla\Application\Events\DeliveryStatusReceived; Event::listen(function (DeliveryStatusReceived $event) { SentMessage::where('provider_id', $event->report->messageId)->update([ 'status' => $event->report->status?->label(), 'delivered_at' => $event->report->delivered() ? now() : null, ]); });
The vendor does not sign its callbacks. The unguessable URL is the only secret. Anyone who learns it can post "Delivered" for any message id, so never let a callback alone unlock an account, mark an invoice paid, or otherwise move something that matters. Treat it as a hint, and verify against a message id your own send recorded.
Polling
If you would rather ask than be told:
$reports = $sms->reportFor(3196185); foreach ($reports as $report) { $report->delivered(); // bool $report->pending(); // still in flight $report->hasError(); // see the note on ErrorCode below $report->cost; // 0.17 $report->status; // DeliveryStatus::Delivered }
DeliveryStatus::isFinal() tells you whether a status can still change —
useful for deciding when to stop polling.
Campaigns and sender IDs
use AhmedLaggoun\SMSAla\Domain\Enums\DeliveryStatus; // Every message in a campaign, optionally filtered $sms->campaign(16139, since: now()->subDay(), status: DeliveryStatus::Delivered); // Sender IDs approved on your account $sms->approvedSenders(); // Pre-flight check before a live campaign if (! $sms->senderIsApproved('YOURBRAND')) { throw new SenderNotApproved; }
The sender list is cached for five minutes by default, so senderIsApproved()
is cheap enough to call in a loop. Adjust with SMSALA_SENDERS_CACHE_TTL, or
clear it after requesting a new sender ID:
use AhmedLaggoun\SMSAla\Domain\Contracts\SenderIdProvider; use AhmedLaggoun\SMSAla\Infrastructure\Gateways\CachedSenderIdProvider; $senders = app(SenderIdProvider::class); if ($senders instanceof CachedSenderIdProvider) { $senders->forget(); }
Error handling
Every exception implements SmsException, so you can catch the whole family or
each case individually.
| Exception | Meaning | Retry? |
|---|---|---|
InvalidMessageException |
Rejected locally. Nothing sent, nothing billed. | No — fix the input |
SmsApiException |
The gateway answered and refused. | Check isRetryable() |
SmsTransportException |
No usable response: DNS, TLS, timeout, unparseable body. | Usually yes |
use AhmedLaggoun\SMSAla\Domain\Exceptions\SmsException; use AhmedLaggoun\SMSAla\Domain\Exceptions\SmsApiException; try { $sms->text($phone, $body); } catch (SmsApiException $e) { $e->statusCode; // HTTP status, when there was one $e->errorCode; // the vendor's own error code $e->isRetryable(); // true for 429 and 5xx } catch (SmsException $e) { report($e); }
A failed send is not automatically retried. A timeout does not tell you
whether the gateway accepted the message, and retrying on a guess is how one
OTP becomes two and one campaign is billed twice. If you need at-least-once
delivery, send with your own reference: and reconcile with a DLR lookup.
Testing
Set SMS_DRIVER=array in phpunit.xml. Nothing reaches the network, no API
token is needed, and the reporting endpoints return empty results instead of
calling out.
<php> <env name="SMS_DRIVER" value="array"/> </php>
Then assert on intent:
use AhmedLaggoun\SMSAla\Domain\Contracts\SmsSender; use AhmedLaggoun\SMSAla\Domain\Enums\MessageType; use AhmedLaggoun\SMSAla\Infrastructure\Gateways\ArrayGateway; app()->instance(SmsSender::class, $sms = new ArrayGateway); $this->post('/register', ['phone' => '918744815388']); expect($sms->wasSent(fn ($m) => $m->type === MessageType::Otp))->toBeTrue(); expect($sms->sent())->toHaveCount(1);
ArrayGateway records the SmsMessage objects your code built, so your
assertions survive a change in the vendor's wire format.
Configuration
Everything is settable from .env; the published config file documents each
value in place.
| Variable | Default | What it does |
|---|---|---|
SMS_DRIVER |
smsala |
smsala, log, or array |
SMSALA_API_TOKEN |
— | Required. Your API token |
SMSALA_SENDER_ID |
— | Default sender for messages that don't set one |
SMSALA_BASE_URL |
https://api2.smsala.com |
Must be https:// |
SMSALA_CALLBACK_URL |
— | Default delivery-webhook URL |
SMSALA_CALLBACK_TOKEN |
— | Enables the webhook route; 32–128 chars |
SMSALA_CALLBACK_PREFIX |
webhooks/sms |
Path the token is appended to |
SMSALA_TIMEZONE |
UTC |
How to read the vendor's offset-less timestamps |
SMSALA_TIMEOUT |
10 |
Request timeout, seconds |
SMSALA_CONNECT_TIMEOUT |
5 |
Connection timeout, seconds |
SMSALA_RETRY_TIMES |
2 |
Attempts for read-only calls |
SMSALA_RETRY_SLEEP_MS |
250 |
Delay between those attempts |
SMSALA_BATCH_SIZE |
100 |
Recipients per request; 0 disables chunking |
SMSALA_CONCURRENCY |
4 |
Batches in flight at once; 1 is sequential |
SMSALA_SENDERS_CACHE_TTL |
300 |
Sender-list cache, seconds; 0 disables |
SMSALA_LOG_CHANNEL |
stack |
Channel used by the log driver |
SMSALA_ALLOW_INSECURE |
false |
Permits http://, for a local mock only |
Bulk sending
10,000 recipients is 100 batches of 100. Sent one at a time that is 100× the
gateway's round-trip; SMSALA_CONCURRENCY sends several at once. Raise it if
your throughput needs it and the gateway tolerates it, or set it to 1 for
strictly sequential behaviour.
API reference
SmsService
| Method | Returns |
|---|---|
send(SmsMessage $message) |
Collection<SmsResult> |
text($to, $text, $type, $encoding, $from, $callbackUrl, $reference) |
Collection<SmsResult> |
otp(string $to, string $text, ?string $reference) |
Collection<SmsResult> |
promotional($to, string $text, ?string $reference) |
Collection<SmsResult> |
reportFor(int $messageId, ?string $userDefinedId) |
Collection<DeliveryReport> |
campaign(int $id, ?DateTimeInterface $since, ?DeliveryStatus $status) |
Collection<CampaignRecord> |
approvedSenders() |
Collection<SenderId> |
senderIsApproved(string $senderId) |
bool |
SmsResult
messageId, operationCode, status, deliveryStatus, reference,
destinationAddress, remarks, callbackUrl — plus accepted(), failed()
and toArray().
DeliveryReport
messageId, destinationAddress, text, messageType, messageLength,
messageParts, cost, status, errorCode, errorDescription, sentAt,
uid, smsId, reference — plus delivered(), pending(), hasError()
and toArray().
Enums
| Enum | Cases |
|---|---|
MessageType |
Promotional (1), Transactional (2), Otp (3) |
DeliveryStatus |
None, Enroute, Delivered, Expired, Deleted, Undeliverable, Accepted, Unknown, Rejected |
MessageEncoding |
Default, Ascii, Octet, Latin1, OctetUnspecified, Cyrillic, LatinHebrew, Ucs2 |
DeliveryStatus adds isFinal(), isFailure() and label().
MessageEncoding adds isUnicode(), charactersPerPart() and
estimateParts().
Security
The package handles the first four of these for you. The last two are yours.
- Credentials never reach a query string or a plaintext connection. Every
call is a POST over TLS; the documented GET variants would put your token in
access logs, proxy logs, APM traces and browser history. A non-
httpsbase URL is refused. If a token has ever been used in a URL, rotate it. - The token is kept out of logs, including out of gateway error bodies, which sometimes echo the request back.
- Message bodies and phone numbers are never logged verbatim — bodies
routinely contain OTPs, and numbers are personal data. That covers log
context, exception messages, and
dd()output. - The webhook endpoint is hardened: rate limited, unguessable, capped in
size, and total in its parsing. It answers
200to everything, because gateways retry non-2xx replies and a 500 turns one bad request into sustained traffic. - Never build a message body from unescaped user input on a sender ID you own. Attacker-controlled text under your brand name is the smishing playbook, and the damage lands on your reputation.
- Authorise the recipient, not just its format. An endpoint where an
authenticated user picks a free-text destination is a billing-drain
primitive.
SmsMessagevalidates the shape of a number; only you know whether this user is allowed to message it.
Found a vulnerability? Please report it privately through GitHub security advisories rather than a public issue.
Notes on the SMSala API
Handled for you, but worth knowing if you compare against the vendor's docs:
ErrorCodemeans two different things. In the response envelope,0is OK. Inside a delivery-report row,1means "No Error". UseDeliveryReport::hasError()rather than testing the number yourself.- Response key casing is inconsistent (
MessageIdvsmessageId,callBackUrlvsCallBackUrl), and some campaign keys contain spaces ("Submission Date"). All reads are case- and punctuation-insensitive, so a change on the vendor's side does not silently become anull. destinationAddresscomes back as a string in most responses and a bare integer in callbacks, which loses leading zeros in a naive parse.- Timestamps carry no UTC offset, so they are ambiguous alone. They are
read as
SMSALA_TIMEZONE(UTC by default) rather than in your app's timezone, which would make the same response mean different things in staging and production. Set it to whatever your account reports in. - Encoding values skip 5, jumping 4 → 6. Undefined values are rejected rather than forwarded.
- Nothing promises one response row per recipient. When the counts disagree, results are reconciled against the recipients you sent.
- No documented rate limit, error-response shape, or maximum recipients per
request. Batches are chunked at 100; raise
SMSALA_BATCH_SIZEonce you have confirmed a real ceiling with support. - The vendor summary mentions an account-balance endpoint, but none is documented. Ask support before relying on low-balance alerts.
Using a different provider
The package is built around three interfaces — SmsSender,
DeliveryReportProvider and SenderIdProvider. Code that only sends
type-hints SmsSender and depends on nothing else, so a second provider is a
new class plus one container binding, and nothing in your application changes.
Cross-cutting behaviour composes as decorators over those same interfaces;
CachedSenderIdProvider is the worked example, and a rate limiter or a metrics
recorder would follow the same shape.
If you do add a provider, treat MessageEncoding and DeliveryStatus as your
own vocabulary and map the vendor's values inside each adapter — they currently
carry SMSala's numeric codes.
Contributing
Pull requests are welcome. The suite runs without network access:
composer test # Pest composer analyse # PHPStan level 8 composer format # Pint composer check # all three
Changelog
See CHANGELOG.md. This package follows semantic versioning; a
breaking change to any interface in src/Domain/Contracts/ is a major bump.
License
MIT — see LICENSE.