omerfayyaz / laravel-sms-gateway
Send and receive SMS in Laravel through a self-hosted SMS gateway that uses Android phones as modems.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- laravel/framework: ^12.18|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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:
- On the gateway's dashboard, open Apps, then your app, then Webhook.
- Enter
https://your-app.example/sms-gateway/webhookand pick the events to receive. - 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.sentcan come aftermessage.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 carriesstatus,errorsandretryAfter.- A
422means the gateway refused the message, for example because the app has no active number to send from. The message says which; checkisValidationError(). - A
401means the key is wrong. - A
403means the key lacks the ability, such as sending.
- A
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.