Key Takeaways
- A Laravel SMS gateway integration is usually an HTTPS driver that talks to an Android phone you control — not a built-in Laravel core feature and not free carrier traffic.
- Wrap the provider behind a notification channel or SmsClient interface so queues, OTP, and webhooks stay portable.
- Send from queued jobs, never from the HTTP request path for OTP at scale; store provider message ids next to your domain ids.
- You supply the Android device and operator SMS credit; service pricing is devices + send volume (free tier available to prove the flow).
- Compare Android SIM economics to Twilio-style aggregator pricing honestly: different cost shape, different number ownership, same need for idempotency.
- HTTPS JSON from a driver you own — not a packaged Laravel SMS SDK we sell. Live fields: Developer Center.
Search laravel sms gateway and you will find packages, Twilio tutorials, and Android phone gateways all mixed together. Laravel itself only gives you HTTP clients, queues, and notifications. The gateway is whatever sits on the other end of HTTPS — including an Android device with a SIM you own. This guide shows how to wire that path cleanly so OTP, bulk, and webhooks do not turn into controller spaghetti.
Priced by devices and SMS send volume. You use your own phone and operator SMS credit.
Composer is not a radio. If the job POSTs HTTPS and the phone is in Doze, Laravel did its part — the SIM did not.
Language samples live on PHP CodeBase; live endpoint shapes live in the SMS API documentation. Sibling posts worth linking while you design: Android SMS gateway API and open source vs maintained.
What “Laravel SMS gateway” means
In practice it means three layers. Your domain code decides when a message should exist (user registered, invoice paid, appointment tomorrow). A Laravel notification channel or dedicated client decides how to call HTTPS. A gateway product decides which radio sends — often a paired Android phone. Confusing those layers is how API keys end up in Blade files and how Twilio tutorials get pasted into Android SIM projects without changing cost assumptions.
You still need a working Android handset and operator SMS credit for the SIM path. The framework does not invent free carrier traffic. Service pricing on a maintained Android gateway is devices plus send volume; details stay on device and SMS volume pricing.
Packages on Packagist can help, but treat them as optional accelerators. Many wrap a single vendor and encode that vendor’s quirks into your app. A forty-line driver you own is often easier to debug at midnight than a clever package with three open issues about Laravel 11 and abandoned maintainers. If you do adopt a package, still hide it behind your interface so the rest of the codebase never imports vendor namespaces directly.
Architecture that survives production
Aim for this flow: HTTP request or domain event → write intent to the database → dispatch queued job → SMS driver calls gateway → persist provider message id → webhook or poll updates status. Controllers stay thin. Jobs own retries. Drivers own vendor quirks. Domain models own business meaning.
That separation lets you swap a marketplace panel, an open-source stack, or a maintained SaaS gateway later. It also lets you fake the driver in tests. If your first Laravel SMS integration is a raw Http::get inside a Livewire action with no queue and no stored ids, rewrite before traffic grows — not after the first outage thread.
A concrete module layout that scales with a small team: app/Sms/SmsGateway.php (interface),app/Sms/AndroidSmsGateway.php (HTTP implementation), app/Sms/FakeSmsGateway.php,app/Notifications/Channels/GatewaySmsChannel.php, app/Jobs/ProcessSmsWebhook.php, and a simple Eloquent model for outbound messages or OTP challenges. Keep vendor-SDK temptation low: we do not sell a composer “complete Laravel SMS SDK.” One HTTP client you understand beats three abandoned packages fighting over Guzzle versions. Live send fields: Developer Center.
Observability belongs in the same module. Emit structured logs with correlation_id,provider_message_id, and notifiable_type — never the plaintext OTP. Metrics that matter: send success rate, p95 time-to-accept from the gateway, queue wait time, and webhook lag. If you only watch HTTP 500s on the web tier, you will miss “OTP sent locally but phone offline for twenty minutes.”
Android SIM gateway vs Twilio-style APIs
| Topic | Android SIM gateway | Twilio-style aggregator |
|---|---|---|
| Number / path | Your SIM / handset | Rented virtual numbers |
| Message cost | Operator airtime + gateway service fee | Per-message aggregator pricing |
| Ops surface | Phone uptime, OEM battery, SIM credit | Vendor SLA, number compliance |
| Laravel wiring | Same: HTTP driver + queue + webhooks | Same patterns, different HTTP client |
| Best when | Local SIMs, cost control, own device path | Global reach, alphanumeric sender needs |
For aggregator docs as a comparison reference, see Twilio SMS documentation. Use it to understand industry patterns (status callbacks, messaging services) — not as a claim that every Laravel app must rent numbers. Official Laravel documentation remains the source for queues and notifications mechanics.
Build a thin SMS driver
Start with an interface your app owns:
namespace App\Sms;
interface SmsGateway {
/** @return provider message id */
public function send(string $to, string $body, string $correlationId): string;
}Implement one class per vendor. Bind the interface in a service provider from config/sms.php. Keep HTTP details — base URL, auth header vs query key, device selection flags — inside the implementation. Controllers never import Guzzle for SMS.
// Conceptual — map fields to Developer Center live params
public function send(string $to, string $body, string $correlationId): string
{
$response = Http::timeout(15)
->withToken(config('sms.key'))
->post(config('sms.send_url'), [
'to' => $to,
'message' => $body,
'reference' => $correlationId,
])
->throw()
->json();
return (string) data_get($response, 'data.messages.0.ID', $response['id'] ?? '');
}Parameter names differ by product. Treat the snippet as structure, then align fields with the SMS API documentation or your chosen vendor’s reference. The important part is correlationId: your OTP challenge id or order id, stable across retries.
Optional methods you may add later without leaking into controllers: getStatus(string $providerId),sendMms(...), and withDevice(?string $deviceId). Grow the interface only when a second call site needs it. Premature “god clients” become untestable. Prefer small interfaces and composition — for example a DeviceSelector collaborator injected into the Android driver.
Number formatting belongs in one helper. Decide whether your app stores E.164 with a plus sign and whether the gateway wants digits only. Normalize once on the way out of the domain layer. Mixed formats are a classic source of “works in Postman, fails in Laravel” bugs that waste an afternoon.
Laravel Notifications pattern
For user-facing messages, a custom notification channel keeps Mail and SMS consistent:
class GatewaySmsChannel
{
public function __construct(private SmsGateway $sms) {}
public function send($notifiable, $notification): void
{
$message = $notification->toGatewaySms($notifiable);
$id = $this->sms->send(
$notifiable->routeNotificationFor('gatewaySms'),
$message->body,
$message->correlationId
);
// persist $id on $message->correlationId row
}
}Notifications should implement ShouldQueue for anything that can wait a second. Synchronous sends inside registration requests create timeout roulette when the phone radio is slow.
Keep template copy in the notification class or a lang file, not in the driver. Drivers transport bytes; product owns wording. That split also makes it obvious when marketing wants to A/B a shipping SMS without touching HTTP code. For transactional messages, avoid deep links that look like phishing; prefer short, expected brand phrasing users already saw in your app UI.
If you also send mail, use the same notification class with via() returning both channels when appropriate. Do not fork “SmsOtpNotification” and “MailOtpNotification” with divergent expiry rules — one source of truth for TTL and code generation prevents nasty mismatches.
Queues, retries, and idempotency
Use Redis or database queues with sensible tries and backoff. On retry, send the samecorrelationId. If the gateway supports idempotency keys, pass them. If not, make your domain idempotent: one challenge row, one active code hash, resends update a counter instead of inventing parallel codes.
Distinguish transport failure from business failure. HTTP 429 or 5xx → retry with backoff. HTTP 200 withsuccess: false and a permanent validation error → fail the job without retry storms. Log provider bodies at info for a short retention window; never log full OTP codes in plain text longer than needed for abuse investigation policy.
Worker topology tips: run at least two queue workers in production so a deploysignal or stuck job does not pause all OTP. Use retryUntil on jobs that must not live forever. When a job exhausts tries, write a terminal failure on the challenge and optionally notify an internal Slack channel — silent dead-letter tables help nobody at 2am.
If you process webhooks on the same Redis connection as sends, watch for head-of-line blocking. Separate connections or queues keep inbound STOP handling from waiting behind a bulk campaign chunk. Horizon supervisors should restart on memory limits appropriate to your payload sizes; SMS jobs are small, but webhook fan-in can surprise you during outages when providers retry aggressively.
OTP verification with Laravel
Recommended shape:
- Create
otp_challengeswith user id, hashed code, expires_at, status, provider_message_id. - Hash the code with a pepper from env; store only the hash.
- Dispatch
SendOtpNotificationon the queue. - On send success, store provider message id.
- Verification endpoint checks hash, expiry, and attempt limits.
- Optional: listen for DLR webhook to mark
deliveredorfailedfor support tooling.
Rate-limit both send and verify routes. Bind OTP to the session or signed user intent so codes cannot be guessed across accounts easily. Prefer short TTLs (3–10 minutes). For product UX patterns, see OTP verification.
Resend flows deserve explicit rules. Cap resends per challenge (for example three). Each resend may rotate the code hash and dispatch a new notification with the same challenge id as correlation root plus a resend index. Invalidate older codes when a new one is issued so two live codes cannot both unlock the account. Show the user a calm cooldown timer instead of a silent button that queues ten jobs.
Support tooling should answer “did the radio path fail or did the user typo?” without reading production logs by hand. A status of accepted_by_gateway, delivered, failed, orunknown on the challenge row saves hours. When status stays unknown past a threshold, page ops to check the handset — do not tell the user to “try again later” forever.
Inbound webhooks and DLR
Expose a single route behind HTTPS, verify signatures or shared secrets, and make handlers idempotent. Gateways retry. Duplicate deliveries must not double-apply STOP or double-credit wallets.
Route::post('/webhooks/sms-gateway', SmsGatewayWebhookController::class)
->middleware(['throttle:60,1']);
// Inside controller: verify secret, normalize event, dispatch ProcessSmsWebhook jobMap inbound SMS to domain actions carefully. STOP should suppress marketing; it should not necessarily delete the user. Keyword bots belong in jobs, not in the webhook controller transaction. More on two-way flows: two-way SMS and webhook docs.
Store raw webhook payloads for a short retention window when debugging new vendors, then trim to normalized events. Index by provider message id and correlation id. If your gateway offers both polling DLR and push webhooks, prefer push for production and keep polling as a reconciliation command you can run from Artisan when webhooks were down.
// Example Artisan reconciliation sketch
php artisan sms:reconcile-pending --older-than=10m
// Loads pending rows, calls gateway status endpoint, updates terminal statesBulk and scheduled sends
Laravel shines at chunked jobs. For list sends, chunk recipients, dispatch batches, and respect quiet hours in your domain — not only in the gateway UI. Android SIM throughput is finite; one phone is not a marketing ESP. Pace jobs. Monitor failure rates. Store per-recipient status.
Scheduled reminders fit Illuminate\\Console\\Scheduling plus queued notifications. Prefer sending at the user’s local quiet-safe window. Feature expectations for spreadsheet-driven campaigns map to bulk SMS from Excel and CSV and scheduled SMS; your Laravel app can own the schedule even when the gateway only offers immediate send.
Use-case framing for campaign vs transactional split: bulk SMS.
Separate queues help under load: sms-transactional and sms-bulk with different worker counts. Never let a 50,000-row campaign starve login OTPs. If you use Laravel batches, define a then/catch that marks the campaign failed when error rates exceed a threshold instead of silently “completing” with half the list undelivered.
Consent is application logic. Store opt-in timestamps and source. Filter bulk queries with those flags. Android gateways will happily send to any number you pass — compliance is your job. Build the filter once in an Eloquent scope and reuse it everywhere marketing wants a “quick blast.”
Multi-device and dual SIM notes
When OTP SLAs matter, configure at least two paired devices and teach your driver how the gateway selects them — round-robin, random, or pinned device id. Laravel should treat device strategy as config, not hard-coded magic in each notification. Dual SIM phones help operator diversity but still fail as a single physical point of failure if the handset dies.
See multi-device and dual SIM for product capabilities to map against your driver options.
Health checks can be a scheduled command that sends a canary SMS to an internal number every hour during business hours and records latency. Alert when canaries fail twice in a row. That beats discovering outages from Twitter complaints. Keep canary volume small so it does not distort cost or trip carrier spam filters.
Config, secrets, and environments
Put keys in .env, never in git. Use separate keys for local, staging, and production when the vendor allows. Staging should send to allowlisted numbers only — a mistaken User::all() notification in staging has ruined more demos than bad CSS. Mirror queue workers in staging so webhook and job bugs appear before production.
Horizon or equivalent helps visibility. Alert on queue depth and failed jobs for the SMS queue specifically; generic “queue is fine” metrics hide a stuck OTP tube.
Example config skeleton:
// config/sms.php
return [
'driver' => env('SMS_DRIVER', 'android'),
'key' => env('SMS_GATEWAY_KEY'),
'send_url' => env('SMS_GATEWAY_SEND_URL'),
'webhook_secret' => env('SMS_GATEWAY_WEBHOOK_SECRET'),
'device_strategy' => env('SMS_DEVICE_STRATEGY', 'round_robin'),
'allowlist' => array_filter(array_map('trim', explode(',', env('SMS_ALLOWLIST', '')))),
];In non-production, if allowlist is non-empty, the driver should refuse recipients outside it. Fail loud in logs. Silent drops create false confidence in QA.
Testing without burning SIMs
Bind a FakeSmsGateway in the testing environment that records sends to an array or database. Feature tests assert correlation ids and recipient routing. Contract tests against the real gateway belong in a small nightly suite with a dedicated test SIM — not in every CI run. Http::fake is fine for driver unit tests when you assert request shapes.
For manual QA, keep a checklist: send, deliver, expire OTP, wrong code, resend, STOP inbound, device offline. Automate what you can; the radio path still needs a human eyeball occasionally.
Pest or PHPUnit examples should cover: notification queues the right channel, fake gateway receives expected body without leaking the raw code into logs, webhook replay with the same event id is idempotent, and allowlist enforcement rejects a random number in staging config. Those four tests catch most regressions teams actually ship.
Cost model for Laravel teams
Engineer time dominates early. A clean driver and notification channel might be a day. Babysitting a self-hosted panel adds ongoing hours. A maintained Android gateway trades subscription cost for fewer PHP panel chores. Either way, operator SMS is yours.
On sms-gateway.app: free tier includes 300 SMS lifetime for proving Laravel wiring; paid plans from $19/month scale with devices and volume. Download the app from download the Android gateway app, then integrate. Compare that spreadsheet to aggregator per-message quotes for your geography before you standardize the company on one path.
Include failure cost. If login SMS is down for an hour during peak signup, how many support tickets and lost conversions is that? Cheap gateways with unpaid ops often lose that math. Expensive aggregators with great SLAs might still lose on local deliverability in markets where a domestic SIM wins. Revisit the spreadsheet quarterly as volume and geography change.
Common Laravel SMS mistakes
- Sending inside controllers without queues.
- Logging raw OTP codes indefinitely.
- No correlation id — impossible to reconcile DLR.
- One device for all production login SMS.
- Webhook routes without signature checks.
- Using marketing bulk patterns for transactional OTP.
- Copying Twilio env var names into an Android gateway driver and wondering why auth fails.
- Skipping rate limits on resend endpoints.
- Running a single queue worker that also processes image uploads and starves SMS jobs.
- Storing phone numbers in inconsistent formats across users and notifications tables.
Fix the list in order of blast radius. Secrets and queues first. Formatting and worker topology next. Copy polish last. Most “Laravel SMS is unreliable” threads are ops and architecture problems wearing a framework costume.
Production checklist
- SmsGateway interface + one real driver + one fake driver.
- Queued notifications/jobs with backoff and idempotent correlation ids.
- OTP hashes, TTL, attempt caps, route throttles.
- Webhook verification + idempotent consumers.
- At least two devices for login-critical traffic.
- Staging allowlist for recipients.
- Alerts on SMS queue failures and device offline signals.
- Runbook: reboot phone, replace SIM, rotate API key.
- Cost sheet: devices, volume plan, operator bundles.
Next steps
Implement the interface, wire a queued notification, and send one OTP to your own handset end-to-end. Read the SMS API documentation for live parameters, skim PHP CodeBase for language samples, then harden with the checklist above. STOP and segments: handle STOP, segment lists. When you outgrow a single SIM, revisit multi-device routing and the setup and scale guide.
Laravel does not care whether the radio is a rented number or your Android phone. Your architecture should not care either — only your cost model and ops runbook should. Build the driver once, queue everything that matters, and keep provider details out of your domain core. Ship the checklist, then measure real OTP latency on hardware you control before you call the integration done.
Related product pages
Jump to the live product docs for this topic—not another long-form article.
- OTP and 2FA SMS on AndroidAuthentication flows
- bulk SMS from Excel and CSVSpreadsheet campaigns
- bulk SMS with consent best practicesHigh-volume outreach
- SMS API documentationLive endpoint reference





