shetabit/sms
Laravel package for sending SMS through multiple gateways using a unified, driver-based API. Configure providers, send messages or OTPs, and switch SMS services without rewriting your application code.
Install and scaffold:
composer require shetabit/sms
php artisan vendor:publish --tag="sms-config" # creates config/sms.php
Put the credentials for the driver you actually intend to use in .env (never hardcode them in config/sms.php), and set the default driver:
SMS_DRIVER=kavenegar
KAVENEGAR_API_KEY=...
Send one message from php artisan tinker before wiring anything into application code:
use Shetabit\Sms\Facade\Sms;
Sms::driver('kavenegar')->to(['09121234567'])->message('test')->send();
If that tinker line works, everything else is just plumbing.
First real use case: the OTP / verification-code path. It is the highest-traffic, highest-risk send in most Iranian-market apps, it is where provider pattern/template APIs matter most, and it is where a driver swap (v1.1.0's whole point) actually pays off.
Where to look first: config/sms.php (driver list and credential shape — this is the file that will bite you on upgrade), then the driver class for your provider under src/Drivers/. Read the driver's send() signature before copying README snippets, because the fluent surface and the raw per-driver API are not always 1:1.
Relying on sms.default makes environment behaviour implicit. In application code prefer the explicit form so a config change on the server can't silently reroute traffic:
Sms::driver(config('sms.default'))->to($numbers)->message($text)->send();
Keep sms.default as the environment switch (local/staging/prod), and pass the driver name explicitly anywhere the business logic depends on the provider.
The facade is a manager, not an abstraction boundary. Put a thin class in front of it so the rest of the app never imports Shetabit\Sms:
final class SmsService
{
public function send(string $text, array $to, ?string $driver = null): void
{
$sms = Sms::driver($driver ?? config('sms.default'));
$sms->to($to)->message($text)->send();
}
}
This is the single most valuable pattern with this package: driver names, recipient-format quirks and provider-specific exceptions all get absorbed in one place, and the v1.1.0 IPPanel driver becomes a config change rather than a code change.
SMS HTTP calls are slow and flaky and will otherwise run inside the request that also has to render the OTP screen.
final class SendSms implements ShouldQueue
{
use Queueable, InteractsWithQueue;
public int $tries = 3;
public int $backoff = 5;
public function __construct(public string $text, public array $to) {}
public function handle(SmsService $sms): void
{
$sms->send($this->text, $this->to);
}
}
For OTP specifically, either send inline with a hard timeout and a short circuit, or queue with a very high priority queue — waiting 20 seconds for a code defeats the purpose.
v1.1.0's IPPanel driver is most useful as a second provider. The realistic production pattern is failover on the cheap providers that occasionally go down or hit credit limits:
public function send(string $text, array $to): void
{
foreach (['kavenegar', 'ippanel'] as $driver) {
try {
Sms::driver($driver)->to($to)->message($text)->send();
return;
} catch (\Throwable $e) {
report($e);
}
}
throw new SmsFailedException('All SMS drivers failed.');
}
Only fail over on transport/credit errors — not on validation errors (bad number, malformed template), which will fail identically on every provider and just double your cost.
IPPanel is an Iranian provider with two distinct send paths: a plain webservice send from a provisioned line, and pattern (template) sends where the message body is registered server-side and you pass variables. For OTP, use the pattern path — it is delivered on dedicated OTP lines, avoids content filtering delays, and is priced better. Credentials are username/password (or an API key depending on account type), so the ippanel block in config/sms.php will not look like the kavenegar block; do not try to normalise the arrays, treat each driver's block as opaque and read the driver source for the exact keys it expects.
Give yourself a no-op driver in config/sms.php for the testing/local environment, or bind a fake SmsService in the container. Asserting on facade calls (Sms::shouldReceive(...)) tests the package, not your code — assert on your own service/queue instead.
The published config does not update itself. This is the main upgrade hazard with this package. config/sms.php is in your repo, but new drivers ship with new config blocks. After composer update, diff the package's config/sms.php against yours and hand-merge. Do not re-run vendor:publish blindly — it will overwrite your env key names and default driver.
v1.1.0 is additive and non-breaking. One new driver (ippanel), one new contributor, no interface changes, no removed config keys. Upgrading mid-sprint is safe; if you don't need IPPanel, upgrading is optional but harmless. If you do want it, the only work is adding the ippanel block and credentials — no code changes if you followed the service-wrapper pattern above.
Recipient formats are not consistent across drivers. Some take an array of strings, some a comma-separated string, some require the sender line separately. Normalise to E.164-ish (+98912...) or a single canonical form in your SmsService and convert per driver — otherwise a driver swap "works" in staging and silently fails on real numbers.
Iranian number normalisation. 09121234567, 9121234567, +989121234567 and 989121234567 are all the same human and all different strings. Normalise on input, before it ever reaches the package.
Errors are provider-shaped. Driver exceptions come from whatever HTTP client the driver wraps; there is no unified exception hierarchy you can rely on. Catch \Throwable at the service boundary, log the provider response body (most providers return a useful error payload), and wrap in your own exception type.
Watch credit before bulk sends. Providers fail partway through a list when credit runs out — you get half a campaign and no clean rollback. For anything over a few hundred messages, chunk the list, check remaining credit first if the driver exposes it, and record per-message success so a retry doesn't double-send.
Duplicate sends on retry. Queued jobs with $tries > 1 plus a provider that accepted the request but timed out on the response will produce duplicates. Make sends idempotent at your layer (a sent_at marker per recipient) rather than trusting the driver.
Extension point: adding your own provider is a matter of implementing the package's driver contract and registering it in the drivers array — worth doing for internal gateways rather than forking. The IPPanel PR in v1.1.0 is a good, small reference implementation to copy from.
Don't dd() inside a driver. Half of these are single-method classes; debugging by dumping in the driver tends to run inside a queued worker and produces silent timeouts instead of output. Log the request/response payload around the call site in your service wrapper instead.
How can I help you explore Laravel packages today?