uzbek/laravel-sms

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

Maintainers

Package info

github.com/professor93/laravel-sms-uzbekistan

pkg:composer/uzbek/laravel-sms

Transparency log

Statistics

Installs: 15

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.2 2026-07-07 13:24 UTC

This package is auto-updated.

Last update: 2026-07-07 13:29:10 UTC


README

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.

Driver Auth Native bulk Delivery status
eskiz login → cached JWT yes (send-batch) pull (checkStatus)
playmobile HTTP Basic yes (messages array) push (webhook)
textup login → cached JWT (separate auth host) same-text only pull (checkStatus)
sayqal per-request signed token no (loops) pull (checkStatus)

Requirements

  • PHP 8.3+
  • Laravel 12 or 13

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

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 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.

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

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

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 for what the class needs to implement.

Sending

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

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

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

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

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

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

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

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

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

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

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

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 formto()->...->send() fails over as a whole. sendMany()/many() has its own per-message bulk fallback — see 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

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

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

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). 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

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

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

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)

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

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

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 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=<PLAYMOBILE_WEBHOOK_SECRET>

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

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

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 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.

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

<?php

declare(strict_types=1);

namespace Uzbek\Sms\Drivers;

use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Http\Client\Factory as HttpFactory;
use Uzbek\Sms\Authenticators\ApiKeyAuthenticator;
use Uzbek\Sms\Contracts\Authenticator;
use Uzbek\Sms\Contracts\ChecksDeliveryStatus;
use Uzbek\Sms\Data\SentMessage;
use Uzbek\Sms\Enums\DeliveryStatus;

final class AcmeDriver extends AbstractDriver implements ChecksDeliveryStatus
{
    public function name(): string
    {
        return 'acme';
    }

    public static function resolveAuthenticator(
        array $config,
        CacheRepository $cache,
        HttpFactory $http,
    ): Authenticator {
        return new ApiKeyAuthenticator('X-API-Key', (string) $config['api_key']);
    }

    protected function doSend(string $phone, string $text): SentMessage
    {
        $phone = $this->normalizePhone($phone);

        $response = $this->http()->post('messages', [
            'to' => $phone,
            'body' => $text,
            'sender' => $this->config['from'],
        ]);

        return SentMessage::success(
            provider: $this->name(),
            phone: $phone,
            text: $text,
            providerMessageId: (string) $response->json('message_id'),
            raw: (array) $response->json(),
        );
    }

    public function checkStatus(string $providerMessageId): DeliveryStatus
    {
        $response = $this->http()->get("messages/{$providerMessageId}")->throw();

        return $this->mapStatus((string) $response->json('state'));
    }

    private function normalizePhone(string $phone): string
    {
        return preg_replace('/\D+/', '', $phone) ?? $phone;
    }

    private function mapStatus(string $status): DeliveryStatus
    {
        return match ($status) {
            'queued' => DeliveryStatus::Pending,
            'sent' => DeliveryStatus::Sent,
            'delivered' => DeliveryStatus::Delivered,
            'undelivered' => DeliveryStatus::Undelivered,
            'failed' => DeliveryStatus::Failed,
            default => DeliveryStatus::Unknown,
        };
    }
}

Then the config block:

// config/sms.php
'providers' => [
    'acme' => [
        'driver' => 'acme',
        'enabled' => (bool) env('ACME_ENABLED', true),
        'base_url' => env('ACME_BASE_URL', 'https://api.acme.example/v1'),
        'api_key' => env('ACME_API_KEY'),
        'from' => env('ACME_FROM'),
        'http_options' => [],
    ],
],

And one line in DriverFactory::DRIVERS:

'acme' => AcmeDriver::class,

Everything else — events, logging, retries, prefix rules, the fluent builder, webhook routing — comes from the base class and the provider wiring for free. If the provider has a native bulk endpoint, override the protected doSendMany() and route results through finalizeBulk(); if it signs requests or logs in for a token, reuse SignedTokenAuthenticator or LoginTokenAuthenticator the same way the built-in drivers do.

Testing

composer test

The suite uses Pest with Http::fake() throughout — no network access, no live credentials.

License

MIT. See LICENSE.md.