Search by

omerfayyaz / laravel-sms-gateway

omerfayyaz

Send and receive SMS in Laravel through a self-hosted SMS gateway that uses Android phones as modems.

Package info

github.com/omerfayyaz/laravel-sms-gateway

pkg:composer/omerfayyaz/laravel-sms-gateway

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-09-24 15:22 UTC

This package is auto-updated.

Last update: 2026-09-24 15:31:37 UTC


README

Send and receive SMS from a Laravel app through an SMS gateway that you host yourself, with Android phones as the modems. The package sends messages, looks them up, sends Laravel notifications as SMS, and turns the gateway's signed webhooks into Laravel events.

  • Every send carries an idempotency key, so a retry never sends a second SMS.
  • Queued notifications keep their key across retries.
  • SmsGateway::fake() records messages in tests instead of sending them.

Requirements

  • PHP 8.2 or newer, and Laravel 12.18 or newer, or 13.
  • The gateway, and an app created on its dashboard under Apps, which gives the API key.

Install

composer require omerfayyaz/laravel-sms-gateway

Add to .env:

SMS_GATEWAY_URL=https://sms-gateway.vividsol.pk
SMS_GATEWAY_KEY=smsgw_...           # shown once, when the app is created
SMS_GATEWAY_WEBHOOK_SECRET=whsec_... # shown once, when the app's webhook is created
SMS_GATEWAY_COUNTRY_CODE=92          # optional: 0300 1234567 becomes +923001234567

Then send yourself a message to check the setup:

php artisan sms-gateway:send +923001234567

To change the timeouts, the retries or the webhook path, publish the config file:

php artisan vendor:publish --tag=sms-gateway-config

Sending

use OmerFayyaz\SmsGateway\Facades\SmsGateway;
use OmerFayyaz\SmsGateway\SmsMessage;

$message = SmsGateway::send('+923001234567', 'Your code is 482913');

$message->id;     // the gateway's id, for find() and webhook events
$message->status; // MessageStatus::Pending
$message->from;   // the number the gateway chose to send from

SmsMessage sets everything else:

SmsGateway::send(
    SmsMessage::create('The mehndi starts at 7pm tomorrow.')
        ->to('+923001234567')
        ->from('+923454805588')                  // one of the app's numbers
        ->sendAt(now()->addHours(3))             // held on the gateway until then
        ->expiresAt(now()->addHours(5))          // dropped if not sent by then
        ->idempotencyKey("mehndi-reminder-{$event->id}-{$guest->id}"),
);

The gateway accepts the message and queues it; a phone sends it shortly after. Its outcome arrives later, by webhook.

Idempotency

A request that cannot reach the gateway, or that fails on its side, is tried again, twice by default. Each retry carries the same idempotency key, so the gateway returns the message it already queued instead of sending another. When send() is called again with a key the gateway has seen, it returns that first message with replayed set.

Without a key of your own, each call to send() makes one. That covers the retries within the call, but not a job that runs twice. For those, give the message a key made from what it is for, as in the example above. Queued notifications do this for you.

The gateway remembers a key for good, so a key should name one send, not a recipient: a second reminder under reminder-{$guest->id} would never go out. The same key with another number or text is refused with a 409. Keys are at most 64 characters.

Notifications

Add 'sms-gateway' to the notification's channels, and say what to send:

use OmerFayyaz\SmsGateway\SmsMessage;

class InvitationAccepted extends Notification implements ShouldQueue
{
    use Queueable;

    public function via(object $notifiable): array
    {
        return ['mail', 'sms-gateway'];
    }

    public function toSmsGateway(object $notifiable): SmsMessage|string
    {
        return "{$this->guest->name} accepted your invitation.";
    }
}

The number comes from the notifiable:

public function routeNotificationForSmsGateway(Notification $notification): ?string
{
    return $this->phone;
}

or is given on demand:

Notification::route('sms-gateway', '+923001234567')->notify(new InvitationAccepted($guest));

A notifiable without a number is skipped. Each notification is sent with the notification's id as its idempotency key. A queued notification (ShouldQueue) keeps that id when its job is retried, so a retry cannot send the SMS twice. A notification sent straight away gets a new id each time: if the code that sends it can run twice, queue the notification or give the message a key of your own.

Looking messages up

$message = SmsGateway::find($id); // null if the app has no message with this id

foreach (SmsGateway::messages(['status' => MessageStatus::Failed]) as $message) {
    // pages are fetched as you go, newest first
}

The list holds the app's own messages, and the SMS that arrived for it (to filters by recipient; for an arrived SMS that is the number it arrived on).

Webhooks

The package registers POST /sms-gateway/webhook. The route sits outside the web middleware, so no session or CSRF token is involved; the signature authenticates each event. To set it up:

  1. On the gateway's dashboard, open Apps, then your app, then Webhook.
  2. Enter https://your-app.example/sms-gateway/webhook and pick the events to receive.
  3. Put the signing secret it shows into SMS_GATEWAY_WEBHOOK_SECRET.

Each event becomes a Laravel event:

Gateway event Laravel event Means
message.sent MessageSent A phone handed the message to the network
message.delivered MessageDelivered The carrier confirmed delivery
message.failed MessageFailed It could not be sent; failureReason says why
message.expired MessageExpired It was not sent before it expired
message.received MessageReceived An SMS arrived for the app

Each of these carries $event->message (a GatewayMessage), $event->eventId and $event->occurredAt. Every event, including ones this version doesn't know, also arrives raw as WebhookReceived.

use OmerFayyaz\SmsGateway\Events\MessageReceived;

class RecordRsvpReply implements ShouldQueue
{
    public function handle(MessageReceived $event): void
    {
        Guest::where('phone', $event->message->from)->first()?->recordReply($event->message->content);
    }
}

Events arrive at least once, and not always in order:

  • The gateway retries an event for about a day until it gets a 2xx answer. A repeat of an event already handled is answered but not dispatched again, for two days, using the app's cache. While one request is handling an event, a second copy is told to come back later.
  • If any listener throws, the event is not remembered. The gateway's retry dispatches it again, to every listener, including the ones that succeeded the first time.
  • After an outage, retries arrive in their own time, so message.sent can come after message.delivered. Don't let a status move backwards.

So make listeners safe to run twice, and use $event->eventId to tell repeats apart. A listener that replies should key its SMS by the event, so a second run sends nothing new:

SmsGateway::send(SmsMessage::create('Thanks, we got your reply.')
    ->to($event->message->from)
    ->idempotencyKey('reply-'.$event->eventId));

Queue listeners that do slow work, so the gateway gets its answer quickly.

To register the route yourself, set SMS_GATEWAY_WEBHOOK_PATH=null, then add it where no CSRF check runs, such as routes/api.php. In routes/web.php the CSRF check would refuse every event:

use OmerFayyaz\SmsGateway\Webhooks\VerifyWebhookSignature;
use OmerFayyaz\SmsGateway\Webhooks\WebhookController;

Route::post('hooks/sms', WebhookController::class)->middleware(VerifyWebhookSignature::class);

Errors

Everything thrown extends OmerFayyaz\SmsGateway\Exceptions\SmsGatewayException.

  • RequestFailed: the gateway answered with an error. It carries status, errors and retryAfter.
    • A 422 means the gateway refused the message, for example because the app has no active number to send from. The message says which; check isValidationError().
    • A 401 means the key is wrong.
    • A 403 means the key lacks the ability, such as sending.
  • ConnectionFailed: the gateway could not be reached, or did not answer in time, even after the retries.

After a ConnectionFailed, or a RequestFailed with a 5xx status, the SMS may still have been queued. Both carry the idempotencyKey that was used; sending again with it is safe, because the gateway answers with the message it already has.

Testing

use OmerFayyaz\SmsGateway\Facades\SmsGateway;

$sms = SmsGateway::fake();

// ... code that sends, directly or by notification ...

$sms->assertSentTo('+923001234567', fn (SmsMessage $message) => str_contains($message->content, 'accepted'));
$sms->assertSentCount(1);
$sms->assertNothingSent();

To test your webhook listeners, post an event signed the way the gateway signs it:

use OmerFayyaz\SmsGateway\Webhooks\WebhookSignature;

$body = json_encode(['id' => 'evt_1', 'type' => 'message.received', 'created_at' => now()->toIso8601String(), 'data' => [/* message */]]);

$this->call('POST', '/sms-gateway/webhook', server: [
    'CONTENT_TYPE' => 'application/json',
    'HTTP_X_GATEWAY_SIGNATURE' => WebhookSignature::header($body, config('sms-gateway.webhook.secret')),
], content: $body)->assertNoContent();

License

MIT. See LICENSE.md.