Search by

smstunnel / smstunnel-notifier

narcisbodea

SMSTunnel transport for Symfony Notifier: send texts from your own Symfony apps through your own Android phone, from your own number.

Package info

github.com/narcisbodea/smstunnel-symfony-notifier

Homepage

Documentation

Type:symfony-bundle

pkg:composer/smstunnel/smstunnel-notifier

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-11-03 19:56 UTC

This package is auto-updated.

Last update: 2026-11-03 20:34:47 UTC


README

smstunnel/smstunnel-notifier: a Symfony Notifier transport for SMSTunnel. Your Symfony app sends texts through your own Android phone, from your own number.

Your app hands each text to your SMSTunnel account and your phone sends it from its own SIM, so recipients see a number they know and can reply to. It plugs into the Notifier's texter slot like Symfony's own SMS bridges: a DSN in .env, then TexterInterface or the sms channel of your notifications.

SMSTunnel for Symfony Notifier is made by NARBOWEB SRL. It is not affiliated with, endorsed by, or sponsored by Symfony SAS or the Symfony project. Symfony is a trademark of Symfony SAS.

Your phone needs the SMSTunnel app from Google Play:

Get it on Google Play

Requirements

  • PHP 8.1 or newer
  • Symfony 6.4, 7.4 or 8.x (symfony/notifier and symfony/http-client)

Get your key

  1. Install the SMSTunnel app from Google Play on your Android phone and sign in. Sign in at smstunnel.io on your computer with the same account: the phone appears under Phones (you can also link it with the QR code from Phones → Add phone).
  2. In your SMSTunnel dashboard, open Apps → Add an app.
  3. Create its key and copy it. The key can send texts and look up whether they went out (sms:send and sms:read).

Installation

composer require smstunnel/smstunnel-notifier

Symfony Flex enables the package's bundle by itself. (Without Flex, add SmsTunnel\Symfony\Notifier\SmsTunnelNotifierBundle::class => ['all' => true], to config/bundles.php.)

Put the DSN in .env.local (never in .env, which is committed), or in the secrets vault with bin/console secrets:set SMSTUNNEL_DSN:

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

Then name it as a texter transport:

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

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 quota.

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
Part Meaning
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 (async)

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())). The transport keeps the key it makes inside them until the text goes through, and Messenger carries it from one attempt to the next. For a text tied to something in your app, set your own key instead (->idempotencyKey('order-1042-shipped')).

A text without SmsTunnelOptions gets a new key on every attempt. That still makes retries by the HTTP client safe (for example framework.http_client.default_options.retry_failed), but not retries of the whole message.

Errors that a retry can't fix (401, 403, 400) 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:

Key Meaning
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, e.g. ['clean' => 95, 'total' => 'unlimited'].
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:

Exception When
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 is reached.
ApiException Any other error answer: 400 (a number SMSTunnel cannot read, a sign-in code text without a code), 404, 409 (IDEMPOTENCY_KEY_IN_PROGRESS: the first request with this key is still running), 422 (IDEMPOTENCY_KEY_REUSED: the key was used for a different text), 5xx. The three above extend it.

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.

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
}

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.'));

Notes and limits

  • Rate limit: 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.
  • More about the API: smstunnel.io/docs. Your keys: dashboard.smstunnel.io/apps.

Build it with an AI assistant

Paste this into Claude, ChatGPT, Cursor or Copilot, and replace the part in brackets. Never paste your real API key into a prompt: the prompt doesn't need it.

I want my Symfony app to send texts with SMSTunnel. SMSTunnel sends each text from my own Android phone, from my own number: the phone runs the SMSTunnel app and is linked to my SMSTunnel account.

Use the SMSTunnel transport for Symfony Notifier:
- composer require smstunnel/smstunnel-notifier (Symfony Flex enables its bundle; without Flex, add SmsTunnel\Symfony\Notifier\SmsTunnelNotifierBundle to config/bundles.php).
- The DSN goes in the environment: SMSTUNNEL_DSN=smstunnel://API_KEY@default (optional ?device_id=PHONE_ID&default_country_code=40), and config/packages/notifier.yaml gets framework.notifier.texter_transports.smstunnel: '%env(SMSTUNNEL_DSN)%'.
- Send with Symfony\Component\Notifier\TexterInterface: $texter->send(new SmsMessage('+14155550123', 'text')), or with Notifier notifications on the 'sms' channel. Per-text options: SmsTunnel\Symfony\Notifier\SmsTunnelOptions with deviceId(), defaultCountryCode(), idempotencyKey() and signInCode().

API facts:
- The transport calls POST https://smstunnel.io/api/v1/sms/send with the header X-API-Key and the JSON body {"to", "message", "deviceId"?, "defaultCountryCode"?}. SentMessage::getMessageId() is the text's ID.
- One recipient per call. To text several people, send one SmsMessage per number.
- signInCode() uses POST /api/v1/sms/send-2fa, only for sign-in codes: the text must contain a code of 4 to 10 digits standing on its own (e.g. 482913 or 123-456), at most 320 characters; it uses a separate quota.
- Numbers in international format (+14155550123). defaultCountryCode (e.g. "40") completes numbers written without + or 00.
- Limits: 30 send requests a minute per key, 1600 characters a text, plus the plan's daily limit.
- 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 or set idempotencyKey() (e.g. "order-1042-shipped").
- Errors (all are Notifier TransportExceptions): 401 AuthenticationException (bad key), 403 PermissionException (getErrorCode() such as PERMISSION_REQUIRED, getPermission()), 429 RateLimitException (getRetryAfter() seconds from the Retry-After header), other 4xx/5xx ApiException (getStatusCode()), and a plain TransportException when SMSTunnel can't be reached.
- If the phone is offline, the text waits on the account and goes out when the phone is back (SentMessage::getInfo('queued') is true on Symfony 7.3+).

What I want: [describe what should send a text, to whom, and when]

Rules:
- Keep the DSN with the API key in .env.local or Symfony secrets (bin/console secrets:set SMSTUNNEL_DSN). Never put the key in source code, in the repository or in client-side code.
- Validate phone numbers before sending, and handle every error above: log it clearly, don't retry 401/403, wait Retry-After and retry 429.
- Never resend a text after a timeout or a 5xx unless the retry carries the same idempotency key.
- Only send texts people expect (alerts to myself, reminders, sign-in codes, order updates).

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).

Development

composer install
composer test       # PHPUnit: the transport (HTTP mocked), the DSN factory, and a Symfony kernel with the bundle
composer analyse    # PHPStan, level max
composer cs         # PHP CS Fixer, Symfony rules

The end-to-end tests run only against a test server, never production. They read the server address and a test key from a JSON file ({"serverUrl": "http://127.0.0.1:3141", "apiKey": "...", "deviceId": "..."}):

SMSTUNNEL_E2E_KEY_FILE=/path/to/test-key.json vendor/bin/phpunit --testsuite e2e

For a test server over plain HTTP, the DSN takes secure_http=false (smstunnel://KEY@127.0.0.1:3141?secure_http=false). Don't use it with a real key.

Changelog

See CHANGELOG.md.

Security

If you discover a security issue, please email contact@narboweb.ro instead of opening an issue.

License

MIT, © NARBOWEB SRL. See LICENSE.