SMSTunnel for Symfony Notifier

Your Symfony app's texts, from your own phone and number

Composer package · PHP 8.1+ · Symfony 6.4, 7.4, 8 · MIT
composer require smstunnel/smstunnel-notifier

A texter transport, like Symfony's own SMS bridges

Your app hands each 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. A DSN in your environment, then TexterInterface or the sms channel of your notifications. You can cut the app off at any time by deleting its API key in your dashboard.

What it does

Texter
TexterInterface and SmsMessage send a text from your own phone, one number per message.
Notifications
The sms channel of Notifier notifications, as with any SMS bridge.
Sign-in codes
A code of 4 to 10 digits, on its own daily allowance.
Several phones
One transport per phone, or a phone chosen for each text.
Messenger
Async sending; with SmsTunnelOptions attached, its retries never send the same text twice.
Requirements
PHP 8.1 or newer, Symfony 6.4, 7.4 or 8 (symfony/notifier and symfony/http-client).

1. Install

composer require smstunnel/smstunnel-notifier

Symfony Flex enables the package's bundle by itself. Without Flex, add it to config/bundles.php:

SmsTunnel\Symfony\Notifier\SmsTunnelNotifierBundle::class => ['all' => true],

2. Get your key

  1. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick Symfony, name it (for example "My Symfony app") and click Create the key.
  3. 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My Symfony app can't send texts anymore.

Get the steps with your key in them

It opens Add an app with Symfony picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.

The key goes in the DSN below. Keep it out of .env, which is committed: use .env.local or Symfony's secrets.

3. Add the DSN

Put the DSN in .env.local (never in .env, which is committed):

# .env.local
SMSTUNNEL_DSN=smstunnel://YOUR_API_KEY@default

Or keep it in the secrets vault:

bin/console secrets:set SMSTUNNEL_DSN

Then name it as a texter transport:

# config/packages/notifier.yaml
framework:
    notifier:
        texter_transports:
            smstunnel: '%env(SMSTUNNEL_DSN)%'

4. Send a text

use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\TexterInterface;

final class OrderShipped
{
    public function __construct(private TexterInterface $texter)
    {
    }

    public function notify(string $phone, int $orderId): void
    {
        $sent = $this->texter->send(new SmsMessage($phone, "Your order #$orderId has shipped."));

        $sent?->getMessageId(); // keep it to look up the text in your dashboard
    }
}

One SmsMessage goes to one number. To text several people, send one message per number. The text always goes out from your phone's own number, so the from argument of SmsMessage is not used.

With notifications

The sms channel of the Notifier works as it does with any SMS bridge:

use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

$notifier->send(
    new Notification('Reminder: your appointment is tomorrow at 10:30.', ['sms']),
    new Recipient(phone: '+14155550123'),
);

Options for one text

use SmsTunnel\Symfony\Notifier\SmsTunnelOptions;
use Symfony\Component\Notifier\Message\SmsMessage;

$sms = new SmsMessage('+14155550123', 'Your order #1042 has shipped.');
$sms->options((new SmsTunnelOptions())
    ->deviceId('PHONE_ID')            // send through this phone (IDs are under Phones in the dashboard)
    ->defaultCountryCode('40')        // completes numbers written without + or 00
    ->idempotencyKey('order-1042-shipped'));

$texter->send($sms);

Options take precedence over the DSN's device_id and default_country_code.

Sign-in and verification codes

$sms = new SmsMessage('+14155550123', 'Your sign-in code is 482913');
$sms->options((new SmsTunnelOptions())->signInCode());

$texter->send($sms);

The text must contain the code, 4 to 10 digits standing on their own (482913 or 123-456); SMSTunnel refuses it otherwise. Up to 320 characters. Codes count against a separate allowance. What counts as a code

Several phones

Name one transport per phone and pick it per text:

framework:
    notifier:
        texter_transports:
            shop: '%env(SMSTUNNEL_DSN)%'
            support: 'smstunnel://%env(SMSTUNNEL_API_KEY)%@default?device_id=%env(SUPPORT_PHONE_ID)%'
$texter->send((new SmsMessage('+14155550123', 'We got your request.'))->transport('support'));

The DSN

smstunnel://API_KEY@default?device_id=PHONE_ID&default_country_code=40
API_KEY
Your key from Apps → Add an app. URL-encode it if it contains special characters.
default
SMSTunnel at https://smstunnel.io.
device_id
Optional. Send through this phone; without it, SMSTunnel picks one of your phones.
default_country_code
Optional. Completes numbers written without + or 00, e.g. 40 (write %2B40 if you want the +). It wins over the phone's own country.

Sending from Messenger

When SmsMessage is routed to a Messenger transport, a worker sends it and Messenger retries it after a failure. Every request this transport makes carries an Idempotency-Key: when SMSTunnel sees the same key again within 24 hours, it answers with the first result instead of sending a second text.

So that Messenger's retries keep the same key, attach SmsTunnelOptions to the texts you send asynchronously, even with no option set (->options(new SmsTunnelOptions())). For a text tied to something in your app, set your own key instead (->idempotencyKey('order-1042-shipped')).

Errors that a retry can't fix (401, 403, 400, and for 24 hours a 5xx stored under the same key) come back on every attempt: set a small max_retries on that Messenger transport, or route such failures to a failure transport.

What you get back

send() returns a SentMessage: getMessageId() is SMSTunnel's ID for the text. On Symfony 7.3 and newer, getInfo() also has:

queued
true when the phone was offline: the text waits on your account and goes out when the phone is back.
branded
true when the text carries a short SMSTunnel signature (plans with a free allowance).
remaining
What is left of today's quota.
idempotency_key
The key the request carried.
replayed
true when SMSTunnel had already accepted this text (same key): no second text went out.
sign_in_code
true for a text sent with signInCode().

Errors

Every failure is a Notifier TransportException, so the Notifier and Messenger handle it as they do for any bridge. When SMSTunnel answered, it is one of these, all in SmsTunnel\Symfony\Notifier\Exception:

AuthenticationException (401)
The API key is wrong, was deleted, or its account is suspended.
PermissionException (403)
The key lacks a permission (getErrorCode() is PERMISSION_REQUIRED, getPermission() names it), or the plan does not include the feature.
RateLimitException (429)
Too many requests (30 a minute per key): getRetryAfter() gives the seconds to wait. Without Retry-After, the plan's limit for the day or month is reached.
ApiException
Any other error answer: 400 (a number SMSTunnel cannot read, a sign-in code text without a code), 404, 409 (the first request with this key is still running), 422 (the key was used for a different text), 5xx. The three above extend it.
use SmsTunnel\Symfony\Notifier\Exception\ApiException;
use SmsTunnel\Symfony\Notifier\Exception\RateLimitException;
use Symfony\Component\Notifier\Exception\TransportExceptionInterface;

try {
    $texter->send(new SmsMessage('+14155550123', 'Your table is ready.'));
} catch (RateLimitException $e) {
    // wait $e->getRetryAfter() seconds, then send the same SmsMessage again (same key, no second text)
} catch (ApiException $e) {
    $logger->warning('SMSTunnel {status} {code}: {message}', [
        'status' => $e->getStatusCode(), 'code' => $e->getErrorCode(), 'message' => $e->getMessage(),
    ]);
} catch (TransportExceptionInterface $e) {
    // network problem: try again later with the same SmsMessage
}

getStatusCode() (also getCode()) is the HTTP status, getMessage() SMSTunnel's own explanation, getBody() the decoded answer. A plain TransportException (not an ApiException) means SMSTunnel could not be reached at all.

Without FrameworkBundle

The transport works with the Notifier component on its own:

use SmsTunnel\Symfony\Notifier\SmsTunnelTransportFactory;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Texter;
use Symfony\Component\Notifier\Transport;

$transport = (new Transport([new SmsTunnelTransportFactory()]))->fromString($_ENV['SMSTUNNEL_DSN']);
$texter = new Texter($transport);

$texter->send(new SmsMessage('+14155550123', 'Your order #1042 has shipped.'));

Limits worth knowing

  • 30 send requests a minute per key. Plans also have daily limits: see Pricing.
  • Length: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
  • Numbers: use the international format (+14155550123). For local numbers, set default_country_code.
  • 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 text messages from my Symfony app 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:
- Use the SMSTunnel transport for Symfony Notifier: composer require smstunnel/smstunnel-notifier (PHP 8.1+, Symfony 6.4, 7.4 or 8). Symfony Flex enables its bundle; without Flex, add SmsTunnel\Symfony\Notifier\SmsTunnelNotifierBundle::class => ['all' => true] to config/bundles.php.
- The DSN goes in .env.local or Symfony's secrets (bin/console secrets:set SMSTUNNEL_DSN), never in .env: SMSTUNNEL_DSN=smstunnel://API_KEY@default, optionally ?device_id=PHONE_ID&default_country_code=40. config/packages/notifier.yaml gets framework.notifier.texter_transports.smstunnel: '%env(SMSTUNNEL_DSN)%'.
- Send with Symfony\Component\Notifier\TexterInterface: $texter->send(new SmsMessage('+14155550123', '...')), or with Notifier notifications on the 'sms' channel. SentMessage::getMessageId() is the text's ID. One SmsMessage goes to one number: send one per person. The text always goes out from my phone's own number, so SmsMessage's from argument is not used.
- Per-text options: SmsTunnel\Symfony\Notifier\SmsTunnelOptions with deviceId(), defaultCountryCode(), idempotencyKey() and signInCode(); they take precedence over the DSN's device_id and default_country_code.
- Sign-in codes: ->signInCode() (POST /api/v1/sms/send-2fa): the text must contain the code as 4 to 10 digits standing on their own (or 123-456), at most 320 characters. Codes have their own daily allowance.
- Every send carries an Idempotency-Key header: a retry with the same key within 24 hours gets the first answer back instead of a second text. For texts sent from Messenger workers, attach SmsTunnelOptions (even with no option set) or set idempotencyKey() (e.g. "order-1042-shipped"), so Messenger's retries keep the same key.
- Errors, all Notifier TransportExceptions, in SmsTunnel\Symfony\Notifier\Exception: AuthenticationException (401, bad key), PermissionException (403; getErrorCode() such as PERMISSION_REQUIRED, getPermission()), RateLimitException (429; getRetryAfter() seconds; without it, the plan's daily or monthly limit is reached), ApiException for other answers (400, 404, 409, 422, 5xx; getStatusCode(), getErrorCode(), getMessage()), and a plain TransportException when SMSTunnel can't be reached.
- Numbers in international format. default_country_code (e.g. "40") completes numbers written without + or 00 and wins over the phone's own country. Texts up to 1600 characters; 30 send requests a minute per key, plus the plan's daily limit. If the phone is offline, the text waits and goes out when it is back (getInfo('queued') is true on Symfony 7.3+).
- 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

Built for alerts to yourself and your team, and texts people expect from you. One text goes to one number. Sending unsolicited messages from a consumer SIM generally breaches mobile carrier terms of service.

Symfony is a trademark of Symfony SAS. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by Symfony SAS or the Symfony project.

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play