SMSTunnel for Laravel
Laravel notifications as texts from your own phone
One line in your app, a text from your number
Your app hands the text to your SMSTunnel account and your own Android phone sends it from its own SIM, so people see a number they know and can reply. You can stop an app, or delete its key, in your dashboard at any time.
$user->notify(new OrderShipped($order)); 1. Install
composer require smstunnel/smstunnel-laravel
The service provider is registered automatically (package discovery). It also installs
smstunnel/smstunnel-php,
the PHP client it builds on. PHP 8.1 or newer.
2. Get your key
- 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
- 2. In your dashboard, open Apps → Add an app, pick PHP or Laravel, name it (for example "My Laravel app") and click Create the key.
- 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My Laravel app can't send texts anymore.
Get the steps with your key in them
It opens Add an app with PHP or Laravel picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.
The key can send texts and look up whether they went out. It goes in your .env, below.
3. Add it to your config
Add the key to config/services.php and .env:
'smstunnel' => [
'key' => env('SMSTUNNEL_API_KEY'),
'device_id' => env('SMSTUNNEL_DEVICE_ID'), // optional: send through this phone
'default_country_code' => env('SMSTUNNEL_DEFAULT_COUNTRY_CODE'), // optional, e.g. +44, for numbers without + or 00
], SMSTUNNEL_API_KEY=sk_live_...
Leave device_id empty to let SMSTunnel pick one of your phones. A
url key points the channel at a self-hosted SMSTunnel; the default is
https://smstunnel.io.
4. Send a notification
Return smstunnel (or SmsTunnelChannel::class) from
via() and add a toSmsTunnel() method:
use Illuminate\Notifications\Notification;
use NotificationChannels\SmsTunnel\SmsTunnelMessage;
class OrderShipped extends Notification
{
public function __construct(private Order $order) {}
public function via(object $notifiable): array
{
return ['smstunnel'];
}
public function toSmsTunnel(object $notifiable): SmsTunnelMessage
{
return SmsTunnelMessage::create("Your order #{$this->order->id} has shipped.");
}
} toSmsTunnel() can also return a plain string.
Tell the channel which number to text with a routeNotificationForSmstunnel() method on
the notifiable, for example your User model. Use the international format, such as
+14155550123:
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
public function routeNotificationForSmstunnel(Notification $notification): ?string
{
return $this->phone_number;
}
}
When it returns null or an empty string, nothing is sent, so users without a number
are skipped. To text someone who is not a user, send an on-demand notification:
use Illuminate\Support\Facades\Notification;
Notification::route('smstunnel', '+14155550123')->notify(new OrderShipped($order));
Notifications that implement ShouldQueue are sent from your queue like any other
channel, which keeps your requests fast.
Message methods
| Method | Description |
|---|---|
| SmsTunnelMessage::create(string $content) | A new text. |
| content(string $content) | The words of the text, up to 1600 characters (10 joined SMS). |
| deviceId(?string $deviceId) | Send through this phone instead of services.smstunnel.device_id. |
| defaultCountryCode(?string $defaultCountryCode) | E.g. +44, for numbers given without + or 00. Wins over services.smstunnel.default_country_code. |
| verificationCode(bool $verificationCode = true) | Send it as a sign-in or verification code (see below). |
Sign-in and verification codes
public function toSmsTunnel(object $notifiable): SmsTunnelMessage
{
return SmsTunnelMessage::create("Your sign-in code is {$this->code}")->verificationCode();
}
SMSTunnel checks that the text contains the code: 4 to 10 digits standing on their own, like
482913, or 123-456. It refuses the text otherwise. Codes count
against a separate allowance, and the text can be up to 320 characters.
Several numbers
If routeNotificationForSmstunnel() returns a list of numbers, the channel sends one
text to each of them (repeats are skipped). The NotificationSent event then carries a
list of results.
When a text isn't sent
The channel dispatches Laravel's NotificationFailed event and throws the exception
again, like the other notification channels. The event's data holds
message, exception and to (the number).
With several numbers, the others are still tried before the exception is thrown.
use Illuminate\Notifications\Events\NotificationFailed;
use Illuminate\Support\Facades\Event;
Event::listen(function (NotificationFailed $event) {
if ($event->channel === 'smstunnel') {
logger()->warning('Text not sent', ['to' => $event->data['to'] ?? null, 'error' => $event->data['message'] ?? null]);
}
});
The exceptions come from smstunnel/smstunnel-php, all under
SmsTunnel\Exception\SmsTunnelException:
| Exception | When |
|---|---|
| AuthenticationException (401) | The API key is wrong, was deleted, or its account is suspended. |
| PermissionException (403) | The key lacks a permission, or the plan does not include the feature. |
| RateLimitException (429) | Too many requests; getRetryAfter() says how many seconds to wait, and a queued notification is retried by your queue. Without it, the plan's limit for the day or month is reached. |
| ApiException | Any other error answer, e.g. 400 for a number SMSTunnel cannot read or a verification text without a code. |
| TransportException | SMSTunnel could not be reached. After a timeout the text may still go out, so a retry can send it a second time. |
A missing API key throws InvalidConfiguration; a notification without
toSmsTunnel() throws CouldNotSendNotification.
The client on its own
The package registers SmsTunnel\Client in the container, configured from
services.smstunnel:
use SmsTunnel\Client;
$status = app(Client::class)->getMessageStatus($messageId); // needs sms:read
$phones = app(Client::class)->getDevices(); Limits worth knowing
- 30 send requests a minute. A queued notification that hits the limit is retried by your queue.
- Length: up to 1600 characters; verification code texts up to 320.
- Numbers:
default_country_codecompletes numbers written without+or00, and it takes precedence over the phone's own country setting. - Offline phone: the text waits on your account and goes out when the phone is back online.
Build it with an AI assistant
Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.
Help me send Laravel notifications as text messages with SMSTunnel.
About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.
What I want: [describe what should send a text, to whom, and when]
How it works:
- Install the notification channel: composer require smstunnel/smstunnel-laravel (Laravel 10, 11, 12 or 13; PHP 8.1+). The service provider registers itself; it installs the PHP client smstunnel/smstunnel-php.
- config/services.php: 'smstunnel' => ['key' => env('SMSTUNNEL_API_KEY'), 'device_id' => env('SMSTUNNEL_DEVICE_ID'), 'default_country_code' => env('SMSTUNNEL_DEFAULT_COUNTRY_CODE')]; the key goes in .env as SMSTUNNEL_API_KEY.
- In a notification: via() returns ['smstunnel'] (or SmsTunnelChannel::class), and toSmsTunnel(object $notifiable) returns NotificationChannels\SmsTunnel\SmsTunnelMessage::create('...') or a plain string. Message methods: content(), deviceId(), defaultCountryCode(), verificationCode().
- The notifiable needs routeNotificationForSmstunnel(Notification $notification): ?string returning the number in international format; null or '' skips it. For someone who is not a user: Notification::route('smstunnel', '+14155550123')->notify(...). If it returns a list, each number gets its own text.
- Sign-in codes: SmsTunnelMessage::create("Your sign-in code is {$code}")->verificationCode(); the text must contain the code as 4 to 10 digits (or 123-456), up to 320 characters.
- Failures dispatch Illuminate\Notifications\Events\NotificationFailed (data: message, exception, to) and rethrow: AuthenticationException 401, PermissionException 403, RateLimitException 429 (getRetryAfter()), ApiException (400, 5xx), TransportException (network), all under SmsTunnel\Exception\SmsTunnelException. Use ShouldQueue so a queue retries.
- defaultCountryCode (e.g. +44) completes numbers written without + or 00 and takes precedence over the phone's own country setting. Texts up to 1600 characters; 30 send requests a minute. If the phone is offline, the text waits and goes out when it is back.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.
Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts. Responsible use
Send only texts that people expect from you: alerts to yourself, appointment reminders, sign-in codes, order updates. Unsolicited messages from a personal SIM break most carriers' terms of service. Please make sure your usage complies with your carrier's terms and with local regulations (in the EU, that includes GDPR and ePrivacy rules on electronic communications).
This package is not affiliated with, endorsed by, or sponsored by Laravel or its owners. Laravel is a trademark of its respective owner.