amidesfahani/laravel-sms

A provider-neutral SMS delivery package for Laravel: runtime-configurable gateways, logical templates, per-gateway parameter mapping, structured delivery results, message and attempt auditing.

Maintainers

Package info

github.com/amidesfahani/laravel-sms

pkg:composer/amidesfahani/laravel-sms

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-08-31 13:29 UTC

This package is auto-updated.

Last update: 2026-08-31 13:30:54 UTC


README

A provider-neutral SMS package for Laravel. Gateways, templates and per-gateway parameter mapping live in the database and are editable at runtime; providers are code.

Status: M7. One templated message, routed to the gateways that serve its destination country, failed over across them in priority order, recorded as a message plus one attempt per handover, synchronously or through the queue, with a structured result. Drivers: Kavenegar, SMS.ir, IPPanel and Melipayamak (Iranian), Twilio (international), plus log, which writes to a log channel and contacts nobody. Sensitive messages and package-owned OTP are built, delivery status can be refreshed from Twilio and IPPanel, and a gateway that stops answering is skipped for a cooldown instead of costing every message a timeout. Webhooks and any administration UI are not built.

⚠️ No driver has ever made a real request to its provider. Every one is written from official documentation and verified against faked responses. Treat a first production send as the real test.

Requirements

PHP 8.3+, Laravel 13+. An APP_KEY is required — gateway credentials are encrypted at rest.

The cache store used for the processing lock must support atomic locks: database, redis, memcached, dynamodb or array. The file store does not and will throw rather than silently permit duplicate sends.

Install

composer require amidesfahani/laravel-sms
php artisan vendor:publish --tag=sms-config
php artisan migrate

That is the whole installation. The service provider is discovered automatically, and the five package migrations are loaded from the packagephp artisan migrate runs them where they are, and they stay in step with the package when it is upgraded. Publishing them is offered (--tag=sms-migrations) for the one case that needs it: a project that must edit the schema, which then owns the copies. Do one or the other, not both.

The config file is the opposite way round: publish it, because it is yours to edit. Everything unset in your copy falls back to the package's defaults.

Then switch it on, per environment:

SMS_ENABLED=true

It is off by default, and that is deliberate. Gateways live in the database, so a production database restored onto a staging machine arrives complete with working credentials, enabled gateways and real people's real numbers. Sending has to be opted into by an environment file, never inherited from a dump. With it off, every message is still recorded, as suppressed, and nothing reaches a phone.

Configure a gateway

use Amid\Sms\Models\SmsGateway;

SmsGateway::create([
    'key' => 'kavenegar-main',
    'label' => 'Kavenegar',
    'driver' => 'kavenegar',          // a key of config('sms.drivers')
    'sender' => '30001234',
    'priority' => 10,                 // lower goes first
])->forceFill([
    'credentials' => ['api_key' => '...'],   // encrypted at rest
    'is_enabled' => true,                    // gateways are created disabled
])->save();

Where a gateway sends

A gateway declares which countries it is meant to serve. This is runtime configuration on the gateway row — no driver knows it exists, and the same driver can serve one account permitted to message thirty countries and another permitted to message one.

use Amid\Sms\Enums\CountryPolicy;

// Everywhere. The default; a gateway that ignores this behaves as it always did.
$gateway->country_policy = CountryPolicy::All;

// Only these countries.
$gateway->country_policy = CountryPolicy::Allow;
$gateway->countries = ['IR'];

// Everywhere except these. Twilio, for instance, documents that it does not
// deliver to Iran, Syria or Cuba.
$gateway->country_policy = CountryPolicy::Deny;
$gateway->countries = ['IR', 'SY', 'CU'];

Codes are ISO 3166-1 alpha-2, normalised on write (trimmed, uppercased, de-duplicated) and validatedIRAN, 123 and UK are all refused. The United Kingdom is GB; accepting UK silently would give you a gateway that never sends and no error to explain why.

The destination's country is derived once, from the normalised number, and stored on the message as country_code. A gateway that does not serve it is never contacted: no request, no attempt row, no provider failure, no failover budget spent. That is routing, not failure.

⚠️ Coverage is not permission. It says what an account is for; it cannot say what the provider will accept. A Twilio gateway configured for the UAE is routed to correctly and may still be refused with error 21408 because that account's Geo Permissions are off — which is a real attempt, a real error, and ordinary failover.

Some valid numbers belong to no country — satellite and international-network ranges. The package does not invent one for them: country_code is null, all still serves them, allow does not, and deny does.

Define a message

A template is a logical message. It is not "a pattern" or "a text" — how it is carried is a property of the template/gateway pairing, so the same message can be a registered pattern at one provider and free text at another.

use Amid\Sms\Enums\DeliveryMode;
use Amid\Sms\Models\SmsTemplate;
use Amid\Sms\Models\SmsTemplateGateway;

$template = SmsTemplate::create([
    'key' => 'order-created',
    'name' => 'Order created',
    'body' => 'Hi {customer_name}, order {order_number} for {total} is placed.',
]);

SmsTemplateGateway::create([
    'sms_template_id' => $template->id,
    'sms_gateway_id' => $kavenegar->id,
    'mode' => DeliveryMode::Pattern,
    'pattern_code' => 'order-created',     // what THIS provider calls it
    // The parameters IN ORDER. Kavenegar numbers rather than names them, so the
    // position of an entry is what decides which token a value becomes.
    'parameter_map' => [
        ['provider' => 'token', 'variable' => 'customer_name'],
        ['provider' => 'token2', 'variable' => 'order_number'],
        ['provider' => 'token3', 'variable' => 'total'],
    ],
]);

SmsTemplateGateway::create([
    'sms_template_id' => $template->id,
    'sms_gateway_id' => $smsir->id,
    'mode' => DeliveryMode::Pattern,
    'pattern_code' => '100200',
    // The same message at a provider that names its parameters instead.
    'parameter_map' => [
        ['provider' => 'CUSTOMER', 'variable' => 'customer_name'],
        ['provider' => 'ORDER_NO', 'variable' => 'order_number'],
        ['provider' => 'AMOUNT', 'variable' => 'total'],
    ],
]);

Leave parameter_map null and the template's own variable names are used, in body order.

The mapping is an ordered list, not an object

parameter_map is a JSON array, and the array order is the parameter order. That is not a stylistic preference: a JSON object has no ordering contract, and MySQL normalises the key order of one when it stores it — sorted by key length, then bytewise. At a provider that numbers its parameters, that order is the difference between a customer's name and the amount they owe, so it is stored as something that is ordered by definition.

provider may be omitted or null for a provider that only counts its parameters:

'parameter_map' => [
    ['variable' => 'customer_name'],
    ['variable' => 'order_number'],
],

At a provider that does name its parameters, an entry with no provider falls back to your own variable name. A map that is stored as an object, has a duplicate provider, or has a malformed entry is refused as a gateway configuration failure rather than guessed at — nothing is sent, and the message can still go out through a gateway whose mapping is intact.

Sensitive messages

A template can be marked as carrying something that must not be kept:

SmsTemplate::create([
    'key' => 'login-otp',
    'name' => 'Login code',
    'body' => 'Your login code is {code}.',
    'is_sensitive' => true,
]);

A caller can also force it for one send, which raises sensitivity and can never lower it:

Sms::to($phone)->template('login-otp')->with([...])->sensitive()->send();

For a sensitive message the package records that it was sent, to whom, through which gateway and with what result — and deliberately does not record what it said:

Ordinary Sensitive
sms_messages.body rendered text null
sms_messages.variables the values null
sms_attempts.provider_payload the response null
sms_attempts.error the provider's words null
sms_attempts.delivery_error the delivery reason null
LogDriver output the body metadata plus [sensitive content omitted]

Null rather than masked: "******" looks like data, and the fact worth recording is that the value was deliberately omitted.

⚠️ No free-form provider text is persisted for a sensitive message at all — not the refusal, not the delivery reason. Several providers quote the request back inside an error ("the text «...» was rejected"), and a scrub that removes the values it happens to know about is a partial defence presented as a guarantee: it cannot remove a one-character value without destroying every diagnostic that contains that character, which is exactly the exemption that made the earlier version unsafe. The audit trail is the structured facts — outcome, failure kind, both policy flags, gateway, driver, sequence, provider message id — and all of them are kept.

⚠️ The consequence is intended: a sensitive message cannot be re-sent from history. There is nothing to rebuild it from. An expired code should be re-requested, not replayed. Immediate failover and queue retry of the same job are unaffected — the values are still in flight.

Queued jobs are encrypted (Laravel's ShouldBeEncrypted), so a queued code is never sitting in clear text in a jobs row or a Redis key. This applies to every send job, not only sensitive ones.

One-time codes

The package generates and verifies the code; a gateway only delivers it. That is what lets one code fail over between providers — a provider-generated code exists only inside that provider.

use Amid\Sms\Facades\Otp;
use Amid\Sms\Otp\OtpStatus;

$result = Otp::send($phone, 'login-otp');

match ($result->status) {
    OtpStatus::Sent       => 'ask for the code',
    OtpStatus::Cooldown   => "wait {$result->retryAfter}s",
    OtpStatus::Unknown    => 'it may have arrived; ask for the code',
    OtpStatus::Failed     => 'try again',
    OtpStatus::Suppressed => 'sending is off in this environment',
};

// The third argument is the PURPOSE, which defaults to the template key on send.
if (Otp::verify($phone, $typed, 'login-otp')) {
    // your application decides what that means
}

⚠️ The purpose is required on verify(). It is the only thing that says which challenge is being answered, and a signature that let you omit it would quietly reject every correct code.

An OTP template is an ordinary template: bound to gateways the ordinary way, routed by capability and country, failed over by the ordinary rules. There is no OTP gateway, no OTP driver and no sms.otp_driver.

The code arrives as the logical variable code, so each gateway's parameter_map translates it into whatever that provider calls it. A caller supplying code itself is rejected.

Purposes. One number can hold several challenges at once:

Otp::send($phone, 'login-otp', purpose: 'login');
Otp::send($phone, 'confirm-otp', purpose: 'withdrawal');

The purpose defaults to the template key.

What it guarantees. The code is stored hashed, never in plaintext. Cache keys are a SHA-256 of the canonical number and the purpose, so the store is not an enumerable list of who is being messaged. A code is single use. A wrong guess costs an attempt and does not extend the expiry; spending the attempt budget destroys the challenge, so the correct code fails afterwards too. A resend inside the cooldown issues nothing and leaves the existing code valid; after it, the new code replaces the old one immediately.

⚠️ Otp::send() never returns the code, and there is no accessor for it. A result carrying it would put it in every stack trace and exception report. Bind your own OtpCodeGenerator in tests if you need to know it.

⚠️ Every OTP send is sensitive, whether or not the template says so — OTP safety must not depend on somebody having ticked a box.

Defaults (config/sms.php): 6 digits, 180s expiry, 90s resend cooldown, 5 attempts. Otp::send() is synchronous: a code is worth ninety seconds, and immediate multi-gateway failover already provides the availability a queue would have been for.

The package supplies the challenge and nothing else — no routes, no controllers, no middleware, and no notion of a user. When to challenge somebody is your application's decision.

Send

use Amid\Sms\Facades\Sms;

Sms::to('09121234567')
    ->template('order-created')
    ->with([
        'customer_name' => 'Amid',
        'order_number' => 'CF-1204',
        'total' => '1,850,000',
    ])
    ->about($order)   // optional context, recorded and never interpreted
    ->queue();        // or ->send() to deliver during this request

Variables are logical names and plain values. Never a model, never a path like order.customer.name — the package has no way to resolve one and no business knowing what your models are called. Deciding when to send is your application's job; this package only carries the message.

What throws and what is recorded

A caller mistake throws, before anything is written or sent: no template, an unknown template key, an unusable phone number, a variable the wording needs that you did not supply.

Everything from the gateway onward is recorded, never thrown: no enabled gateway, a provider refusal, a timeout. Sending is almost always a side effect of something more important, and an exception there would roll back the order that the message was merely announcing. Read the outcome off the message and its attempts.

Results

Every driver returns a SendResult, and nothing above a driver reads an HTTP status or an exception message to decide what happens next:

Field Meaning
outcome accepted, rejected or uncertain
failureKind provider-neutral classification of a failure
retryableOnSameGateway trying this same gateway again could plausibly work
safeToFailover known not-sent, so another gateway may carry it without duplicating
providerMessageId the provider's own id, for delivery lookups and disputes
error the reason, truncated, with every configured credential stripped out

uncertain is the case that matters. A timeout, or a 5xx, means the request arrived and may have been processed — so the message settles as unknown and is never automatically re-sent. Assuming otherwise is how one order confirmation becomes two.

Failover

A message is offered to each eligible gateway in priority order until one takes it. Every handover is one sms_attempt row, in sequence, so the history of a message survives its outcome.

The chain stops at the first of these:

Result What happens
accepted message is accepted; earlier failed attempts stay in the history
uncertain message is unknown and the chain stops permanently
rejected, not safe to fail over message is failed, no further gateway is tried
rejected, safe to fail over the next eligible gateway is tried

uncertain never fails over, and that is the point of the whole design. A gateway that timed out may already have the message; handing it to a second gateway is how one person receives the same SMS twice.

safeToFailover is only ever true where structured provider evidence shows the failure belongs to that account — rejected credentials, a rate limit, a pattern not registered there. An unexplained refusal is not failed over, because it might equally be a refusal every gateway would repeat. That makes failover deliberately conservative: it fires on evidence, not on hope.

Gateway circuit breaker

Failover already makes one message survive a dead gateway. It does not stop the next message waiting fifteen seconds to discover the same thing. So after a few transport failures in a row, a gateway is skipped for a cooldown:

closed  ──3 qualifying failures in 60s──▶  open  ──60s──▶  half_open  ──one probe──▶  closed
                                                                                  └──▶  open

⚠️ It answers exactly one question: should this application temporarily avoid calling this gateway, because recent transport evidence is bad? Only two failure kinds count — Network and ProviderUnavailable. An invalid recipient, a message the provider will not carry, an unregistered pattern or a rejected credential is neutral: none of them says the gateway cannot be reached, and none of them improves in sixty seconds. A delivery report never affects it either — a switched-off handset is not a transport fault.

⚠️ Skipping is routing, not failure. An open gateway is not called, records no sms_attempt, produces no provider error and consumes no sequence number: the next gateway becomes attempt 1.

⚠️ It can never rescue the message that tripped it. Health is recorded after an attempt, and an uncertain result still stops that message as unknown with no failover — the provider may already have it. The evidence is for the next message.

⚠️ It is local evidence about one account, not knowledge that a provider is down. A rate-limited account looks identical from here.

State lives in the cache, keyed by the gateway's id and the second its row was last saved — so correcting a gateway's credentials produces a fresh circuit and nobody has to find a reset button before the fix takes effect. Nothing secret or personal goes into a key.

When every eligible gateway is open: a synchronous send fails immediately with a package-authored reason and no request; a queued send is left unsettled so the existing job retry brings it back, and only settles failed once its attempts are spent.

For a management layer:

$breaker = app(Amid\Sms\Health\CircuitBreaker::class);

$breaker->status($gateway);   // state (closed|open|half_open), failures, openUntil
$breaker->reset($gateway);    // clears the observation - and nothing else

reset() does not enable a disabled gateway, does not touch priority, credentials or country policy, and sends nothing. Configure it under sms.circuit_breaker; set enabled to false to switch it off entirely.

Message states

queuedsendingaccepted | failed | unknown, plus suppressed when the master switch is off.

Anything other than queued and sending is settled, and a settled message is never delivered again — that is what makes a re-run job, a killed worker or a redeployment safe.

Delivery status

⚠️ A different question from the send outcome, and it must not be confused with it. accepted means the provider took the request. Whether a handset ever received it is answered later, by a different endpoint, and sometimes with the opposite verdict.

Sms::refreshDelivery($message);   // or an SmsAttempt, when you know which handover

$message->delivery_status;        // null | pending | sent | delivered | failed | unknown
$message->delivery_confirmed_at;  // when WE learned it arrived - not the handset's clock

⚠️ delivery_confirmed_at is the moment this package obtained a delivered verdict, which with polling can be well after the phone actually received the message. It is deliberately not called delivered_at: no provider here publishes a trustworthy carrier delivery timestamp, and a column with that name would be displayed as one. delivery_checked_at on the attempt is the last time the provider was asked.

null not tracked — the driver that carried it cannot report delivery
pending accepted, no terminal result yet
sent the carrier has it; the handset is not confirmed
delivered positive confirmation
failed the carrier confirmed non-delivery
unknown a status came back that cannot be mapped truthfully

Supported by twilio (polling the Message resource by SID) and ippanel (the recipient-level report by outbox id). Both declare Capability::DeliveryReport and implement Amid\Sms\Contracts\ReportsDeliveryStatus; every other driver leaves delivery null and needs no method saying so.

Rules worth knowing before you build on it:

  • Explicit only. Nothing polls, nothing is scheduled, and reading delivery_status contacts nobody. Which messages are worth asking about, and how often, is a decision with real cost attached and it belongs above this package.
  • A lookup can never change a send. A report API that times out or rejects your token has told you nothing about the message: the refresh returns null and not one column changes. It never triggers failover or a resend.
  • Terminal verdicts are monotonic. delivered is never downgraded by a stale answer, and a confirmed failure never returns to pending.
  • Only the accepted attempt speaks for the message. Refused failover attempts are never polled and never touch the summary.
  • No raw report is ever persisted. These endpoints return the original message text, the recipient, account and billing detail; only a neutral status, the provider's own status token, a structured error code and a short reason survive — and for a sensitive message, not even the reason.
  • Webhooks (Twilio's StatusCallback) are not implemented. Push can be added later without changing any of the above.

Phone numbers

Destinations are stored canonically in E.164 (+989121234567). sms.phone.default_region is used only for input that carries no country code of its own.

sms.phone.require_mobile is off by default. A parsing library can classify a number, but that classification is not a universal statement about whether the number can receive an SMS — it varies by country and carrier — so Core does not reject valid international destinations on line type alone. Turn it on if your application genuinely wants mobile-only destinations. E.164 validity is checked either way.

Twilio

The international driver. Text only.

SmsGateway::create([
    'key' => 'twilio',
    'label' => 'Twilio',
    'driver' => 'twilio',
    'sender' => '+15551234567',        // -> From, in E.164
    'priority' => 50,
])->forceFill([
    'credentials' => ['account_sid' => 'AC...', 'auth_token' => '...'],
    'is_enabled' => true,
])->save();

Optionally a Messaging Service instead of a sender. Twilio treats these as alternatives, so when this is set it is sent instead of From, never alongside:

$gateway->options = ['messaging_service_sid' => 'MG...'];

⚠️ Twilio does not deliver to Iran. Its own documentation for error 21408 says not to retry expecting Geo Permissions to fix it. So this gateway is additive: it carries the international destinations the Iranian providers cannot, and an Iranian destination refused here fails over to a gateway that can carry it. Put both in the same chain and the router sorts it out.

⚠️ queued means accepted, not delivered. Twilio has taken responsibility for processing the message; the handset has not been reached. The message becomes accepted and the Message SID is stored for a later delivery lookup.

Opt-out never fails over

Twilio error 21610 means the recipient replied STOP. That refusal is recorded with safeToFailover = false, so the chain stops even when healthy gateways remain.

This is deliberate and it is not a technical limitation. Failover exists so a provider outage does not lose a message; using it to reach somebody who asked not to be reached would turn a reliability mechanism into a way of ignoring them, automatically and at scale. If you are building on this package, do not work around it.

Melipayamak

Two things about this provider are worth knowing before you configure it.

There are two Melipayamak APIs. This driver speaks the one every official SDK targets — rest.payamak-panel.com, with a username and password in the request body. An account provisioned only for the newer console API key will not work with it.

Pattern values are joined into one delimited string. The provider has no named parameters here: the approved body has numbered placeholders and the values are matched by position. The separator defaults to ; and is configurable per gateway:

$gateway->options = ['parameter_separator' => ','];

A value that contains the configured separator is refused before the request is made, because sending it would split one value into two and deliver a plausible, billed, wrong message that nothing downstream could detect. The value is never escaped or altered — no escaping mechanism is documented, so any would be a guess. The refusal is safe to fail over, so a gateway that passes parameters as discrete fields can carry the same message unchanged.

IPPanel and IPPanel-derived providers

The ippanel driver targets the current Edge API (https://edge.ippanel.com/v1), and supports text and pattern sending. Its url option overrides the base URL:

$gateway->forceFill([
    'credentials' => ['api_key' => '...'],   // sent as a bare Authorization header
    'options' => ['url' => 'https://api.another-host.example/v1'],
])->save();

That override is the only white-label support offered. A gateway described as "built on IPPanel" is not necessarily API-compatible with it; this driver serves another gateway only when that gateway genuinely exposes the same documented contract at a different address. A provider that has diverged needs its own driver, not a branch inside this one.

Drivers

What this package's drivers actually implement — not what the providers offer.

Driver Text Pattern Delivery report International
log local only; contacts nobody
kavenegar Iranian accounts
smsir Iranian accounts
ippanel Iranian accounts; also serves API-compatible hosts via options.url
melipayamak Iranian accounts
twilio international. ⚠️ cannot deliver to Iran

⚠️ A blank is a statement about this package, not about the provider. Several of these providers publish more than is implemented here, and the difference is deliberate:

  • IPPanel documents voice OTP, scheduled sending, peer-to-peer, keyword, postal-code and country sends, phonebooks and remote pattern management. None of it is exposed.
  • Twilio's Content API (ContentSid / ContentVariables) is its template system and is not implemented, so twilio has no pattern capability: whether SMS content sends require a Messaging Service is not settled by the current documentation, and Test Credentials cannot exercise one.
  • Melipayamak publishes a delivery lookup (GetDeliveries2) that is deliberately not implemented — this provider's documentation is method-specific and easy to misread, and two report implementations were enough to prove the abstraction.
  • Provider-owned OTP exists at several of these providers and is deliberately unused: a code that exists only inside one provider cannot fail over to another.

⚠️ Capability can also depend on the account. A Twilio account with Geo Permissions disabled for a region refuses messages there at runtime; an Iranian line may be approved for service patterns but not for free text. The driver says what the integration supports; the account decides the rest.

Security

  • Gateway credentials are encrypted at rest, hidden from model serialisation, redacted from dumps, redacted out of provider error text and stored payloads, and named — never quoted — when one is missing.
  • ⚠️ A future admin UI must never return a stored secret to a form, and must treat a blank credential input as "leave unchanged". This package deliberately provides no way to read one back.
  • A sensitive message persists no body and no variables, no provider payload, and no free-form provider text at all — providers quote the request back inside refusals. The structured facts remain.
  • ⚠️ The consequence is intended: a sensitive message cannot be re-sent from history. An expired code should be re-requested, not replayed.
  • Queued jobs are encrypted (ShouldBeEncrypted), so a code in flight is never in clear text in a jobs row or a Redis key.
  • One-time codes are stored hashed, never in plaintext, under a cache key that contains a hash of the number rather than the number. No API ever returns the code.
  • ⚠️ Twilio's opt-out (21610) never fails over. Using failover to reach somebody who replied STOP would turn a reliability mechanism into consent bypass. The decision is made twice in the code so a later edit cannot quietly re-enable it.

⚠️ This package does not promise exactly-once delivery, and cannot. It prevents avoidable duplicates: every gateway is called at most once per run, an uncertain result stops the message permanently as unknown rather than being re-sent, and a lock plus a settled-state check makes a re-run job a no-op.

What remains is one unavoidable window, stated plainly: if the process dies after a provider has accepted the request but before the attempt row is written, the message stays unsettled and a later run may hand it to a gateway again — one person, two messages. Closing that would need a two-phase protocol with the provider, and none of these providers offers one. The design keeps the window as small as the write that follows the HTTP call, and makes every knowable ambiguity terminal rather than optimistic.

Operational limitations

  • The cache store must support atomic locksdatabase, redis, memcached, dynamodb or array. The file store does not: the send lock throws rather than permit duplicate sends, and the circuit breaker logs an error and switches itself off rather than run a version of itself whose single-probe guarantee is not a guarantee. Messages still send in that case.
  • Delivery refresh is explicit. Sms::refreshDelivery() is the whole mechanism. Core schedules nothing, polls nothing and has no webhook route; deciding which messages are worth asking about, and how often, is a policy question with real cost and provider rate limits attached, and it belongs above this package.
  • delivery_confirmed_at is when this package obtained a delivered verdict, not when the handset received the message. No provider here publishes a trustworthy carrier timestamp.
  • Country coverage is administrator configuration, not global knowledge about providers. The package does not know which countries an account may message; somebody tells it.
  • The circuit breaker is local evidence about one account. A rate-limited account looks exactly like a provider outage from here. Nothing in this package claims a provider is down.
  • The master switch defaults to off, so a restored production database cannot start sending.
  • ⚠️ Twilio does not replace the Iranian drivers for Iranian destinations — its own documentation says not to retry traffic to Iran expecting Geo Permissions to restore delivery. It carries what they cannot.

Adding a provider

Implement Amid\Sms\Contracts\Driver, declare its capabilities, and register the class in config('sms.drivers'). If the provider can report delivery, also implement Amid\Sms\Contracts\ReportsDeliveryStatus and declare Capability::DeliveryReport — both together or neither, so the capability stays a fact rather than a claim. A driver must not throw for provider behaviour; it translates its provider's vocabulary into a SendResult. Provider quirks — parameter limits, forbidden characters, an id buried in an odd place — belong inside the driver, never in calling code.

Managing credentials (for a future admin layer)

Credentials are encrypted at rest and hidden from every serialisation of the gateway model. Any management UI built on this must never return stored secret values to a form, and must treat a blank credential input as "leave unchanged". Authorization is the management layer's responsibility; this package has no opinion about permissions.

Testing

composer install
./vendor/bin/pest

Tests run against an in-memory SQLite database owned by the test run. No application database is touched and no real request is ever made.

There is one exception, and it does not run by default. tests/Integration holds a Twilio Test Credentials harness that calls the real Twilio REST API — which sends no SMS, contacts no carrier, charges nothing and touches no account state. It is a separate test suite, excluded from the command above, and it skips itself unless you supply the credentials:

SMS_TWILIO_TEST_ACCOUNT_SID=AC... SMS_TWILIO_TEST_AUTH_TOKEN=...   ./vendor/bin/pest --testsuite=Integration

⚠️ Use the test pair from the Twilio Console, shown beside the live pair. Live credentials there would send real messages to real phones and bill you for them.