PHPackages                             uzbek/laravel-sms - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Mail &amp; Notifications](/categories/mail)
4. /
5. uzbek/laravel-sms

ActiveLibrary[Mail &amp; Notifications](/categories/mail)

uzbek/laravel-sms
=================

Unified SMS sending for Uzbek providers — Eskiz, PlayMobile, TextUp and Sayqal behind a single driver contract.

v1.4.0(1mo ago)046MITPHP ^8.3

Since Jul 2Compare

[ Source](https://github.com/professor93/laravel-sms-uzbekistan)[ Packagist](https://packagist.org/packages/uzbek/laravel-sms)[ Docs](https://github.com/professor93/laravel-sms-uzbekistan)[ RSS](/packages/uzbek-laravel-sms/feed)WikiDiscussions Synced 1w ago

READMEChangelog (2)Dependencies (20)Versions (10)Used By (0)

Laravel SMS Uzbekistan
======================

[](#laravel-sms-uzbekistan)

Unified SMS sending for Uzbek providers behind a single contract. Your application code talks to `Uzbek\Sms\Contracts\Driver`; which provider actually delivers the message is a config decision.

DriverAuthNative bulkDelivery status`eskiz`login → cached JWTyes (`send-batch`)pull (`checkStatus`)`playmobile`HTTP Basicyes (`messages` array)push (webhook)`textup`login → cached JWT (separate auth host)same-text onlypull (`checkStatus`)`sayqal`per-request signed tokenno (loops)pull (`checkStatus`)Requirements
------------

[](#requirements)

- PHP 8.3+
- Laravel 12 or 13

Installation
------------

[](#installation)

```
composer require uzbek/laravel-sms
```

The service provider is auto-discovered. Publish the config, then run the migrations (the `sms_logs` table is used by the optional database log):

```
php artisan vendor:publish --tag=sms-config
php artisan vendor:publish --tag=sms-migrations
php artisan migrate
```

Configuration
-------------

[](#configuration)

Pick a default provider and configure the ones you use. Keep the published `config/sms.php` complete — Laravel merges package config at the top level only, so a hand-written partial file would drop the `webhook`/`logging`/`cache` sections entirely.

Each entry under the `providers` array is a **named provider** — the identifier you pass to `sms()`, `DriverFactory::make()` and `useFallback()`, and the value stored in `sms_logs.provider`. Every provider block starts with a `driver` key naming the implementation that actually sends for it:

```
// config/sms.php
'providers' => [
    'eskiz' => [
        'driver' => 'eskiz',
        // ...
    ],
    'playmobile' => [
        'driver' => 'playmobile',
        // ...
    ],
    'textup' => [/* ... */],
    'sayqal' => [/* ... */],
],
```

By default each provider is named after its driver, but that is just a convention, not a rule — see [Multiple accounts / named providers](#multiple-accounts--named-providers) below.

Full `.env` reference:

```
# Core
SMS_PROVIDER=eskiz               # default provider: eskiz | playmobile | textup | sayqal
SMS_WEBHOOK_ENABLED=false        # set true to receive delivery callbacks
SMS_WEBHOOK_PATH=sms/webhooks    # webhook base path
SMS_LOG_DATABASE=false           # set true to log every send to sms_logs
SMS_LOG_DEBUG=false              # write structured entries to the Laravel log
SMS_LOG_CHANNEL=                 # Monolog channel for the debug log (empty = default)
SMS_CACHE_STORE=                 # cache store for auth tokens (empty = app default)
SMS_CACHE_PREFIX=sms             # prefix for all package cache keys
SMS_SILENT=false                 # suppress the unsupported-bulk-fallback warning log

# Eskiz — https://notify.eskiz.uz
ESKIZ_ENABLED=true
ESKIZ_EMAIL=account@example.com
ESKIZ_PASSWORD=secret
ESKIZ_FROM=4546
ESKIZ_TOKEN_TTL=2592000          # seconds; Eskiz JWTs live ~30 days
ESKIZ_ALLOWED_PREFIXES=          # comma-separated, e.g. 99890,99891 (empty = all numbers)
ESKIZ_BLOCKED_PREFIXES=          # comma-separated; wins over the allowed list

# PlayMobile / smsxabar
PLAYMOBILE_ENABLED=true
PLAYMOBILE_USERNAME=login
PLAYMOBILE_PASSWORD=secret
PLAYMOBILE_FROM=3700
PLAYMOBILE_WEBHOOK_SECRET=       # random string; part of the callback URL

# TextUp — https://textup.uz
TEXTUP_ENABLED=true
TEXTUP_EMAIL=account@example.com
TEXTUP_PASSWORD=secret
TEXTUP_NICKNAME_ID=              # optional; empty = the short number
TEXTUP_TOKEN_TTL=86400
TEXTUP_IS_OTP=false

# Sayqal — https://sayqal.uz
SAYQAL_ENABLED=true
SAYQAL_USERNAME=login
SAYQAL_SECRET_KEY=secret
SAYQAL_SERVICE_ID=1
SAYQAL_NICKNAME=
```

Every provider supports the same `*_ALLOWED_PREFIXES` / `*_BLOCKED_PREFIXES` pair (`PLAYMOBILE_...`, `TEXTUP_...`, `SAYQAL_...`) — see [Restricting recipients by prefix](#restricting-recipients-by-prefix).

> **TextUp validates against approved templates.** The message text must exactly match one of your account's approved templates (placeholders filled per the template's pattern, trailing newline included). Text that matches no approved template is rejected by TextUp before delivery. Get your templates approved in the TextUp cabinet first.

Each provider block in `config/sms.php` also accepts `http_options` — raw Guzzle options passed to every request. Uzbek providers usually require a whitelisted static IP, so a proxy is a first-class concern:

```
'providers' => [
    'eskiz' => [
        // ...
        'http_options' => [
            'proxy' => 'http://10.0.5.1:3128',
            'timeout' => 10,
        ],
    ],
],
```

### Multiple accounts / named providers

[](#multiple-accounts--named-providers)

Give two provider entries the same `driver` to run more than one account through the same implementation — useful when, say, marketing and transactional traffic should log in as different Eskiz accounts:

```
// config/sms.php
'providers' => [
    'eskiz'     => ['driver' => 'eskiz', 'email' => env('ESKIZ_EMAIL'), /* ... */],
    'marketing' => ['driver' => 'eskiz', 'email' => env('ESKIZ_MKTG_EMAIL'), /* ... */],
],

// usage
sms('marketing')->to('+998901234567')->text('Salom')->send();
```

`sms(...)`, `->useFallback(...)`, the webhook URL segment (`/sms/webhooks/{provider}`), and the `sms_logs.provider` column all key off the **provider name** (`eskiz`, `marketing`) — not the `driver` they happen to share. Only the four built-in facades (`EskizSms`, `PlayMobileSms`, `TextUpSms`, `SayqalSms`) exist out of the box, so reach for `sms('marketing')` or `app('sms.provider.marketing')` for any extra named provider.

### Custom driver

[](#custom-driver)

A `driver` value can be a built-in name (`eskiz`, `playmobile`, `textup`, `sayqal`) or a fully-qualified class extending `Uzbek\Sms\Drivers\AbstractDriver` — no changes to the package itself required:

```
'providers' => [
    'inhouse' => ['driver' => \App\Sms\InHouseDriver::class, /* ... */],
],
```

See [Adding a new driver](#adding-a-new-driver) for what the class needs to implement.

Sending
-------

[](#sending)

### Fluent

[](#fluent)

```
use Uzbek\Sms\Contracts\Driver;

$message = app(Driver::class)
    ->to('+998901234567')
    ->text('Your code is 4821')
    ->send();
```

The builder also carries per-message options — each just overrides config for that one send:

```
sms('textup')
    ->to('+998901234567')
    ->text('Your code is 1234')
    ->otp()                     // is_otp = true (isOtp() alias)
    ->from('MyBrand')           // sender / nickname override (nickname() alias)
    ->as(['email' => $tenant->email, 'password' => $tenant->pw])  // runtime credentials (usingCredentials() alias)
    ->send();
```

### Direct

[](#direct)

```
$message = app(Driver::class)->send('+998901234567', 'Your code is 4821');
```

### The `sms()` helper

[](#the-sms-helper)

```
sms()->send('+998901234567', 'Your code is 4821');            // default provider
sms('playmobile')->to('+998901234567')->text('Salom')->send(); // named provider
```

The helper is a thin wrapper over the factory — `sms()` is `DriverFactory::default()`, `sms('eskiz')` is `DriverFactory::make('eskiz')`, with the same fail-fast exceptions for unknown or disabled providers.

### Dependency injection

[](#dependency-injection)

The `Driver` contract is bound to the configured default, so constructor injection works anywhere:

```
use Uzbek\Sms\Contracts\Driver;

final class OrderNotifier
{
    public function __construct(private readonly Driver $sms) {}

    public function notifyShipped(Order $order): void
    {
        $this->sms->send($order->phone, "Order {$order->number} shipped.");
    }
}
```

Phone formats are forgiving — each driver normalizes numbers to its provider's expected format (`+998 90 123-45-67`, `998901234567` and `90 123 45 67` variants all work).

### Facades

[](#facades)

Every provider has its own facade, plus `Sms` for the default provider. Each one proxies the exact same singleton the factory returns, so enabled flags, prefix rules, events and logging all apply:

```
use Uzbek\Sms\Facades\Sms;
use Uzbek\Sms\Facades\EskizSms;
use Uzbek\Sms\Facades\PlayMobileSms;
use Uzbek\Sms\Facades\TextUpSms;
use Uzbek\Sms\Facades\SayqalSms;

Sms::send('+998901234567', 'Your code is 4821');          // the default provider
EskizSms::to('+998901234567')->text('Salom')->send();     // a specific provider
SayqalSms::checkStatus($message->providerMessageId);      // capability methods too
```

Root aliases are auto-registered, so `\EskizSms::send(...)` works without an import. A facade whose provider is disabled throws `DriverDisabledException` on its first call — same fail-fast rule as the factory.

### Bulk: one text, many numbers

[](#bulk-one-text-many-numbers)

```
use Uzbek\Sms\Data\OutboundMessage;

$results = app(Driver::class)->sendMany(
    OutboundMessage::sameText(['+998901111111', '+998902222222'], 'We are open today until 20:00')
);
```

### Bulk: a different text per number

[](#bulk-a-different-text-per-number)

```
use Uzbek\Sms\Data\OutboundMessage;

$results = app(Driver::class)->sendMany([
    new OutboundMessage('+998901111111', 'Your table is booked for 19:00'),
    new OutboundMessage('+998902222222', 'Your table is booked for 20:30'),
]);
```

Drivers with a native bulk endpoint (Eskiz, PlayMobile) send the whole list in one HTTP request. TextUp batches when every text is identical and falls back to per-message requests otherwise. Sayqal has no bulk endpoint and always loops. Your code does not change either way.

### Reading results

[](#reading-results)

`send()` returns a `SentMessage`; `sendMany()` returns a `Collection` of them. **The send pipeline never throws** — transport failures come back as failed results, so a partial bulk send reports per-recipient outcomes instead of dying halfway:

```
$results = app(Driver::class)->sendMany($messages);

foreach ($results as $message) {
    if ($message->successful) {
        echo "{$message->phone} → {$message->providerMessageId} ({$message->status->value})\n";
    } else {
        report(new RuntimeException("SMS to {$message->phone} failed: {$message->errorMessage}"));
    }
}

$results->where('successful', false)->count(); // failed recipients
```

`SentMessage` fields: `provider`, `phone`, `text`, `status` (`DeliveryStatus` enum), `successful`, `providerMessageId`, `errorMessage`, `raw` (the provider response, for debugging).

The only exceptions you will ever see are configuration errors at resolution time — see below.

Capability detection
--------------------

[](#capability-detection)

Not every provider supports every feature. Detect capabilities with `instanceof` instead of assuming:

```
use Uzbek\Sms\Contracts\ChecksDeliveryStatus;
use Uzbek\Sms\Contracts\HandlesWebhooks;

$driver = app(Driver::class);

// Status pull — Eskiz, TextUp, Sayqal
if ($driver instanceof ChecksDeliveryStatus) {
    $status = $driver->checkStatus($message->providerMessageId);

    if ($status->isFinal()) {
        // delivered / undelivered / failed — stop polling
    }
}

// Status push — PlayMobile; the package handles the webhook for you,
// this check only matters if you build provider-specific UI
if ($driver instanceof HandlesWebhooks) {
    // callbacks arrive at the webhook endpoint automatically
}
```

Switching and combining providers
---------------------------------

[](#switching-and-combining-providers)

The default provider comes from `SMS_PROVIDER`. To use a specific provider regardless of the default, resolve it by name:

```
use Uzbek\Sms\DriverFactory;

$factory = app(DriverFactory::class);

$factory->make('eskiz')->send($phone, $text);      // marketing route
$factory->make('playmobile')->send($phone, $otp);  // transactional route
```

Or reach for the per-provider facades — `EskizSms::send($phone, $text)` is the same instance behind a static face.

Runtime credentials
-------------------

[](#runtime-credentials)

Pass credentials (or any config keys) at resolution time to send from a different account than `config/sms.php` — useful for multi-tenant apps where each tenant has its own provider login:

```
use Uzbek\Sms\DriverFactory;

app(DriverFactory::class)
    ->make('eskiz', ['email' => $tenant->eskiz_email, 'password' => $tenant->eskiz_password])
    ->send($phone, $text);

// or via the helper
sms('eskiz', ['email' => $tenant->eskiz_email, 'password' => $tenant->eskiz_password])->send($phone, $text);
```

Overrides are merged over the provider's config block. For token drivers (Eskiz, TextUp) each distinct credential set gets its **own cached token**, keyed by a hash of the credentials — so tenants never share or clobber each other's tokens, and the single-flight refresh still applies per account. Overriding a non-credential key (below) reuses the configured account's token, so it costs no extra login.

The same mechanism sets any per-message option. TextUp's `isOtp`, for example, is per send:

```
sms('textup', ['is_otp' => true])->send($phone, 'Your code is 1234');  // this message only
sms('textup')->send($phone, $text);                                    // normal
```

Fallback provider
-----------------

[](#fallback-provider)

For a single send, name a secondary provider to try when the primary fails:

```
$message = sms('eskiz')
    ->to('+998901234567')
    ->text('Your code is 4821')
    ->useFallback('playmobile')
    ->send();
```

The primary sends once. If it returns an unsuccessful `SentMessage`, the fallback sends once and its result is returned. A successful primary never contacts the fallback. Pass a predicate to decide for yourself:

```
->useFallback('playmobile', fn (SentMessage $sent) => $sent->status === DeliveryStatus::Failed)
```

Notes:

- **This is the single-send form** — `to()->...->send()` fails over as a whole. `sendMany()`/`many()` has its own per-message bulk fallback — see [Bulk fallback](#bulk-fallback) below.
- **One secondary** — the fallback is not itself retried.
- **Each attempt is real:** a failed primary and a successful fallback each fire `SmsSent` and (with the database log on) write their own `sms_logs` row — an honest record of "eskiz failed, playmobile delivered."
- Fluent overrides (`otp()`, `from()`, `as()`) apply to the **primary only**; the fallback uses its own config.
- An unknown or disabled fallback provider throws the usual resolution exception, but only if the fallback is actually triggered.

### Bulk fallback

[](#bulk-fallback)

`sendMany()` takes the same idea as `useFallback()`, but per-message: only the recipients whose primary result failed are retried through the fallback, not the whole batch. Two equivalent forms:

```
// Fluent, via many()
$results = sms('sayqal')->many($messages)->useFallback('eskiz')->send();

// Params, directly on sendMany()
$results = sms('sayqal')->sendMany($messages, fallback: 'eskiz', fallbackWhen: fn (SentMessage $m) => ! $m->successful);
```

Both drive the exact same call — `many($messages)->useFallback(...)->send()` just collects the arguments and calls `sendMany($messages, $fallback, $fallbackWhen)` for you. The default predicate is "not successful"; pass your own `fallbackWhen` to decide differently. Only the messages that match are collected and re-sent as one batch through the fallback provider; the returned `Collection` keeps every recipient in their original position, whichever provider's result ended up there.

Not every driver can be trusted with a partial retry — it needs to report *which individual message* failed, not just an all-or-nothing batch result. `Uzbek\Sms\Contracts\SupportsBulkFallback` is the marker interface that says a driver's `sendMany()` qualifies (either it sends one HTTP request per message under the hood, or its native batch endpoint reports success per item). Only `SayqalDriver` implements it today; `EskizDriver`, `PlayMobileDriver` and `TextUpDriver` do not. Passing `fallback` to one of those is a no-op — the primary results come back untouched — and a warning is logged (`SMS provider [x] does not support bulk fallback; returning primary results ...`). Set `SMS_SILENT=true` (`sms.silent`) to suppress that warning project-wide, e.g. if you deliberately pass a fallback to every provider regardless of support.

### Default fallback provider

[](#default-fallback-provider)

Configure a fallback once per provider instead of calling `useFallback()` at every call site:

```
// config/sms.php
'providers' => [
    'eskiz' => [
        'driver' => 'eskiz',
        'fallback' => 'sayqal',
        // ...
    ],
],
```

It is picked up automatically by both fluent builders whenever the call site doesn't set its own `useFallback(...)`:

```
sms('eskiz')->to('+998901234567')->text('Salom')->send();   // falls back to sayqal on failure
sms('eskiz')->many($messages)->send();                      // same, per-message
```

An explicit `useFallback(...)` always overrides the configured default. Call `withoutFallback()` to send with no fallback at all, configured or not:

```
sms('eskiz')->to('+998901234567')->text('Salom')->withoutFallback()->send();
```

The configured default only applies through the fluent builders. The bare `send($phone, $text)` and the raw `sendMany($messages)` call (no `fallback` argument) are unchanged — they never consult it.

Enabling and disabling providers
--------------------------------

[](#enabling-and-disabling-providers)

Every provider block has an `enabled` flag (`ESKIZ_ENABLED`, `PLAYMOBILE_ENABLED`, ...). Configuration problems fail fast at resolution time — before any HTTP request:

```
$factory->make('nexmo');
// Uzbek\Sms\Exceptions\UnknownProviderException:
// SMS provider [nexmo] is not defined. Add a config/sms.php providers block.

// with ESKIZ_ENABLED=false:
$factory->make('eskiz');
// Uzbek\Sms\Exceptions\DriverDisabledException:
// SMS provider [eskiz] is disabled. Enable it via sms.providers.eskiz.enabled.
```

A third exception, `UnknownDriverException`, covers the narrower case where the provider block resolves but its `driver` key names neither a built-in driver nor a valid `AbstractDriver` subclass (see [Custom driver](#custom-driver)). All three extend `Uzbek\Sms\Exceptions\SmsException`. A disabled *default* provider throws on the first `app(Driver::class)` resolution, and webhook requests addressed to a disabled or unknown provider return 404 without leaking why.

Restricting recipients by prefix
--------------------------------

[](#restricting-recipients-by-prefix)

Each provider block accepts a `prefixes` section — an allowed list, a blocked list, or both. By default both are empty and every number goes through.

```
// config/sms.php
'providers' => [
    'eskiz' => [
        // ...
        'prefixes' => [
            'allowed' => [],            // non-empty = only these prefixes may receive SMS
            'blocked' => ['99833'],     // always rejected; wins over the allowed list
        ],
    ],
],
```

Rules are **per provider** — blocking a prefix on `eskiz` says nothing about `playmobile`. Matching is digit-based, so `+998 33`, `99833` and `998-33` all mean the same prefix, whatever format the number arrives in.

A prohibited number is rejected *before* any HTTP request and follows the usual pipeline rules: you get a `SentMessage::failed(...)` carrying the `ProhibitedPhoneException` message, `SmsSent` still fires, the attempt is logged, and the rest of a bulk send continues untouched:

```
config(['sms.providers.eskiz.prefixes.blocked' => ['99897']]);

$results = app(Driver::class)->sendMany(OutboundMessage::sameText(
    ['+998901111111', '+998971111111'], 'Salom'
));

$results[0]->successful;   // true — sent as usual
$results[1]->successful;   // false
$results[1]->errorMessage; // "Phone [+998971111111] matches blocked prefix [99897]. ..."
```

Logging
-------

[](#logging)

Events always fire; both logging channels are optional listeners layered on top.

### Database log (`SMS_LOG_DATABASE`, default off)

[](#database-log-sms_log_database-default-off)

With `SMS_LOG_DATABASE=true`, every send — including failures — becomes an `sms_logs` row; webhook callbacks and status pulls update the row's `status` by `(provider, provider_message_id)`. Query it like any model:

```
use Uzbek\Sms\Models\SmsLog;
use Uzbek\Sms\Enums\DeliveryStatus;

SmsLog::query()
    ->where('provider', 'eskiz')
    ->where('status', DeliveryStatus::Undelivered)
    ->where('created_at', '>=', now()->subDay())
    ->get();

SmsLog::query()->whereNotNull('error')->latest()->limit(20)->get(); // recent failures
```

While the channel stays off (the default), no rows are written — the events still fire and your app can persist whatever it wants.

### Debug log (`SMS_LOG_DEBUG`, default off)

[](#debug-log-sms_log_debug-default-off)

Structured entries (provider, phone, provider message id, status, success flag, error) via Laravel's logger. Point it at a dedicated channel if you like:

```
SMS_LOG_DEBUG=true
SMS_LOG_CHANNEL=sms
```

```
// config/logging.php
'channels' => [
    'sms' => [
        'driver' => 'daily',
        'path' => storage_path('logs/sms.log'),
        'days' => 14,
    ],
],
```

Debug entries never contain credentials, tokens or auth headers.

Events
------

[](#events)

Listen in your app for anything the built-in listeners don't cover:

```
use Uzbek\Sms\Events\SmsSent;
use Uzbek\Sms\Events\DeliveryStatusUpdated;

Event::listen(SmsSent::class, function (SmsSent $event): void {
    if (! $event->message->successful) {
        Notification::route('slack', config('services.slack.ops'))
            ->notify(new SmsFailedNotification($event->message));
    }
});

Event::listen(DeliveryStatusUpdated::class, function (DeliveryStatusUpdated $event): void {
    // $event->provider, $event->providerMessageId, $event->status, $event->raw
});
```

`SmsSent` fires exactly once per message — successes and failures, single and bulk alike.

Webhooks
--------

[](#webhooks)

Webhook routes register at `POST /{SMS_WEBHOOK_PATH}/{provider}` when `SMS_WEBHOOK_ENABLED=true` (off by default — send-only apps expose no endpoint). The route carries only the middleware from `config('sms.webhook.middleware')` — it deliberately sits outside the `web` group and CSRF, because providers POST server-to-server.

### PlayMobile

[](#playmobile)

PlayMobile does not sign callbacks. The shared-secret token is **optional** — leave `PLAYMOBILE_WEBHOOK_SECRET` empty and callbacks are accepted without a token; set it and the token becomes mandatory and must match. Register this URL in the PlayMobile cabinet:

```
https://your-app.uz/sms/webhooks/playmobile?token=

```

Setting a secret is recommended, since the endpoint sits outside `web`/CSRF. Optionally pin the sender IPs in `config/sms.php`:

```
'providers' => [
    'playmobile' => [
        // ...
        'allowed_ips' => ['185.8.212.47'],
    ],
],
```

Wrong token (when a secret is set) or a disallowed IP → 403. Valid callbacks update `sms_logs` (when the database log is on) and dispatch `DeliveryStatusUpdated` either way.

### Eskiz, TextUp, Sayqal

[](#eskiz-textup-sayqal)

No webhook intake in v1 — poll with `checkStatus()`. Eskiz supports a `callback_url` on send (the config key is reserved); a POST to `/sms/webhooks/eskiz` currently returns 404 by design.

Deployment and cache notes
--------------------------

[](#deployment-and-cache-notes)

Eskiz and TextUp tokens are cached and refreshed with a **single-flight** strategy: on a 401, one process takes an atomic lock, re-logs-in and stores the fresh token; concurrent processes wait, then adopt that token instead of logging in again.

- `SMS_CACHE_STORE` (empty = the app default) chooses where tokens live. Atomic locks work on `file`, `database`, `array`, `redis`, `memcached` and `dynamodb` stores — no extra infrastructure needed.
- **Multi-server / serverless (Vapor):** point `SMS_CACHE_STORE` at a *shared* store (`redis`, `database`, `dynamodb`). With a per-container cache the package still works, but each container logs in once and refreshes independently.
- If the chosen store has no lock support, refresh degrades to a lock-less re-login. Duplicate concurrent logins are harmless (providers issue independent tokens); tokens are never lost.
- `SMS_CACHE_PREFIX` (default `sms`) namespaces every key the package writes (`sms:eskiz:token`, `sms:eskiz:token:lock`, ...). Change it if you run several apps against one Redis.

Adding a new driver
-------------------

[](#adding-a-new-driver)

Adding a provider touches nothing existing: extend `AbstractDriver`, implement `doSend()` + `resolveAuthenticator()` + `mapStatus()`, add capability interfaces if the provider supports them, add a config block, and register one line in the `DriverFactory` map so the short name (`'acme'`) is available everywhere. If you only need the driver in your own app, skip that last step entirely and reference the class directly in a provider's `driver` key instead — see [Custom driver](#custom-driver).

A complete worked example — a fictional provider with API-key auth and status pull:

```
