SMSTunnel for Symfony Notifier
Your Symfony app's texts, from your own phone and number
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. Install the SMSTunnel app from Google Play on your Android phone and sign in.
- 2. In your dashboard, open Apps → Add an app, pick Symfony, name it (for example "My Symfony 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 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, setdefault_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.