SOLID, driver-based SMS layer for Laravel with an SMSala gateway.

Maintainers

Package info

github.com/ahmed-laggoun/SMSAla

Homepage

Issues

pkg:composer/ahmedlaggoun/smsala

Transparency log

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-08-20 21:56 UTC

This package is auto-updated.

Last update: 2026-08-20 22:05:53 UTC


README

Send SMS through SMSala from Laravel, with typed responses, delivery-status webhooks, and drivers that keep your test suite off the network.

tests packagist license

$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 driverssmsala for production, log for local development, array for 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-https base 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 200 to 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. SmsMessage validates 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:

  • ErrorCode means two different things. In the response envelope, 0 is OK. Inside a delivery-report row, 1 means "No Error". Use DeliveryReport::hasError() rather than testing the number yourself.
  • Response key casing is inconsistent (MessageId vs messageId, callBackUrl vs CallBackUrl), 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 a null.
  • destinationAddress comes 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_SIZE once 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.