symfony/light-sms-notifier
Symfony Notifier integration for LightSms, letting you send SMS notifications through the LightSms API. Configure it with a simple DSN containing your login, token, and sender phone number to start delivering text messages from your Symfony app.
First, be clear about what this package is: it is not a Laravel package. It is a split-out bridge from the symfony/symfony monorepo that plugs LightSms into the Symfony Notifier component. The "3 stars" is meaningless — releases are cut from the Symfony monorepo on the normal 6.4 / 7.x / 8.x cadence, so treat it as an official, maintained bridge without a Laravel façade.
composer require symfony/light-sms-notifier symfony/notifier symfony/http-client
Pick the version line that matches the Symfony components Laravel already ships with:
| Laravel | Symfony components | Install |
|---|---|---|
| 10 | ^6.4 |
symfony/light-sms-notifier:^6.4 |
| 11 | ^7.0 |
symfony/light-sms-notifier:^7.2 |
| 12 | ^7.2 |
symfony/light-sms-notifier:^7.4 |
| — | ^8.1 (needs PHP ≥ 8.4) |
symfony/light-sms-notifier:^8.1 |
Put it in a config file (never call env() at runtime — see Gotchas):
// config/services.php
'lightsms' => [
'dsn' => env('LIGHTSMS_DSN'), // lightsms://LOGIN:TOKEN@default?from=PHONE
],
// app/Providers/AppServiceProvider.php
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Notifier\Bridge\LightSms\LightSmsTransportFactory;
use Symfony\Component\Notifier\Transport\Dsn;
use Symfony\Component\Notifier\Transport\TransportInterface;
public function register(): void
{
$this->app->singleton(TransportInterface::class, function () {
$factory = new LightSmsTransportFactory(null, HttpClient::create());
return $factory->create(new Dsn(config('services.lightsms.dsn')));
});
}
use Symfony\Component\Notifier\Message\SmsMessage;
public function sendCode(string $phone, string $code): void
{
$this->transport->send(new SmsMessage(
"Your verification code is {$code}. It expires in 10 minutes.",
$phone, // must match /^\+?[0-9]{7,15}$/
));
}
That single send() call is the whole happy path — the bridge builds the HTTP request, authenticates with LOGIN/TOKEN, sets the from sender, and posts to LightSms.
The README is the entire documentation (one DSN example). The real spec is the two classes inside the package: LightSmsTransportFactory (which DSN parts map to what) and LightSmsTransport (request shape, error handling). Read Symfony\Component\Notifier\Transport\Dsn for parsing rules — that's where 90% of integration bugs originate.
Skip Notifier/SmsChannel entirely and inject TransportInterface where you need it. This is the right default in Laravel because you gain nothing from the Notifier abstraction unless you want multi-provider portability.
final class SmsSender
{
public function __construct(private readonly TransportInterface $transport) {}
public function send(string $phone, string $text): void
{
$sent = $this->transport->send(new SmsMessage($text, $phone));
Log::info('lightsms.sent', ['id' => $sent->getMessageId(), 'to' => $phone]);
}
}
send() returns a SentMessage; getMessageId() is your only handle for support tickets with LightSms.
Wrap the transport in a Laravel channel so it composes with your existing Notifiable models, queues, and via() routing.
// app/Notifications/Channels/LightSmsChannel.php
namespace App\Notifications\Channels;
use Illuminate\Notifications\Notification;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Transport\TransportInterface;
class LightSmsChannel
{
public function __construct(private readonly TransportInterface $transport) {}
public function send(object $notifiable, Notification $notification): void
{
if (! $phone = $notifiable->routeNotificationFor('lightsms', $notification)) {
return;
}
/** @var SmsMessage $message */
$message = $notification->toLightSms($notifiable);
// Re-target: the phone lives on the notifiable, not the message
$this->transport->send(new SmsMessage($message->getSubject(), $phone));
}
}
Then in the notification:
public function via($notifiable): array
{
return ['database', LightSmsChannel::class];
}
public function toLightSms($notifiable): SmsMessage
{
return new SmsMessage("Order #{$this->order->id} has shipped.", $notifiable->phone);
}
public function shouldQueue($notifiable): bool // or implements ShouldQueue
{
return true;
}
Register the channel in your provider:
Notification::extend('lightsms', fn ($app) => $app->make(LightSmsChannel::class));
This is the pattern that gives you queueing, retries, failed() hooks, and rate limiting via Illuminate\Notifications\Events — none of which the bridge provides.
Notifier + SmsChannel (only for provider portability)Use this if you genuinely plan to swap LightSms for Twilio/Vonage later without touching call sites.
use Symfony\Component\Notifier\Channel\SmsChannel;
use Symfony\Component\Notifier\Notifier;
$notifier = new Notifier([new SmsChannel($transport)]);
$notifier->send(new OrderShippedNotification($order), new Recipient($user->phone));
SmsChannel::notify() calls $notification->asSmsMessage($recipient), so you need a Symfony\Component\Notifier\Notification\Notification subclass. Note: do not pass a Messenger MessageBusInterface to the Notifier constructor inside Laravel — symfony/messenger and Laravel's bus are unrelated. Leave it null and queue at the Laravel layer (Pattern B).
fromfrom is fixed at DSN time, so one transport = one sender. For per-brand or per-country sender IDs, build a keyed map:
$this->app->singleton('lightsms.transports', function () {
$factory = new LightSmsTransportFactory(null, HttpClient::create());
return collect(config('services.lightsms.accounts'))
->map(fn (string $dsn) => $factory->create(new Dsn($dsn)))
->all();
});
Then resolve by tenant. Cache the transports — HttpClient::create() per request in a loop will leak connections.
Inject MockHttpClient at the factory boundary; no need to mock the bridge itself.
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
$client = new MockHttpClient(new MockResponse('OK', ['http_code' => 200]));
$transport = (new LightSmsTransportFactory(null, $client))
->create(new Dsn('lightsms://u:t@default?from=MyBrand'));
$transport->send(new SmsMessage('hello', '+33612345678'));
$this->assertCount(1, $client->getRequestsCount());
For feature tests, bind TransportInterface to a fake in the container and assert on recorded SmsMessages instead — cheaper and provider-agnostic.
ShouldQueue or a dedicated SendSmsJob) with $tries = 3 and backoff(). The bridge does one HTTP call and throws on failure; Laravel's queue is your retry and dead-letter machinery.RateLimiter::for('sms', fn ($job) => Limit::perMinute(30)) — carriers and LightSms accounts both have throughput ceilings.SentMessage::getMessageId() and store it against the user/order. It's the only correlation ID you get back.Event over Symfony's. AbstractTransport::setEventDispatcher() expects a PSR-14 EventDispatcherInterface; Laravel's dispatcher doesn't implement it, so you'd need an adapter — usually not worth it. Fire your own event in the channel instead.The Notifier name collides. Laravel 11+ ships an Illuminate\Notifications\Notifier and the Notifier facade. If you import Symfony\Component\Notifier\Notifier anywhere near a notification class, you will get confusing type errors. Alias the Symfony one or, better, avoid it (Pattern A/B).
Never leak the DSN into logs. (string) new Dsn($dsn) includes login:token. Most Laravel log contexts that "helpfully" dump config will expose your API token. Add lightsms.dsn to any redaction list, and if you log the transport, log getEndpoint() only.
Percent-encode credentials. The DSN is parsed with parse_url(). A token containing @, :, /, or ? silently corrupts parsing or throws UnsupportedSchemeException / IncompleteDsnException. rawurlencode() the login and token when building the DSN string.
from is not optional. The factory calls getRequiredOption('from'); omitting it throws IncompleteDsnException at container resolution time — i.e. at boot, not at send. Also: ?from=PHONE must be a sender ID/number already provisioned in your LightSms account, or the API will reject with a transport-level error, not a validation error.
The host is required even though it's ignored. lightsms://LOGIN:TOKEN@default — the @default is mandatory syntax. Dropping it produces an incomplete DSN.
Phone numbers are validated by regex, not a library. SmsMessage rejects anything not matching /^\+?[0-9]{7,15}$/. That means no spaces, dashes, parentheses, or 00 international prefixes. Normalise at the boundary (e.g. +33612345678 via propaganistas/laravel-phone) before constructing the message, otherwise you get an InvalidArgumentException mid-job — after the queue has already dispatched.
Exceptions are transport-level and opaque. Failures surface as Symfony\Component\Notifier\Exception\TransportException (or CouldNotSendNotification). The bridge doesn't classify "bad credentials", "insufficient balance", or "invalid recipient" differently. Catch it at the job level, log $e->getMessage(), and do not retry blindly — an auth failure will burn your queue's $tries in seconds. Classify by inspecting the message or by short-circuiting on TransportExceptionInterface from symfony/http-client.
No delivery receipts, no scheduling, no segment counting. The bridge is fire-and-forget. It won't tell you if the SMS was delivered, whether it was split into multiple parts (and billed as such), or whether the encoding fell back to UCS-2. Practical rule: keep messages under 160 characters of GSM-7 (which excludes emoji and most non-Latin scripts — a single emoji drops you to 70 chars/segment). Truncate and template in your own code.
env() outside config files returns null after config:cache. Classic Laravel trap, and it bites harder here because a null DSN produces an UnsupportedSchemeException at boot rather than a clear "missing config" error. Always read via config('services.lightsms.dsn').
Watch the Symfony version skew. Because this ships on Symfony's release train, composer update can try to pull ^8.1 (PHP 8.4 only) if your other constraints drift. Pin the bridge explicitly to your Laravel's Symfony line rather than relying on ^7.0.
Don't reuse one HttpClient instance across a long-running worker if you customise it. In Laravel Octane or queue workers, resolve the transport as a singleton (as shown) so connections are pooled; but if you ever swap in a scoped/mocked client in tests, use $this->app->forgetInstance(TransportInterface::class) between tests.
Extension points are thin — compose, don't fork. The transport is final-ish in spirit and does one thing. If you need custom behaviour (extra API params, different endpoint, retry with jitter at the HTTP layer), decorate:
final class RetryingTransport implements TransportInterface
{
public function __construct(private readonly TransportInterface $inner) {}
public function send(MessageInterface $message): ?SentMessage
{
return retry(3, fn () => $this->inner->send($message), 200);
}
public function supports(MessageInterface $message): bool
{
return $this->inner->supports($message);
}
public function __toString(): string
{
return (string) $this->inner;
}
}
Debugging playbook, in order: (1) dump((string) $transport->getEndpoint()) — confirm you're hitting the expected endpoint; (2) log the raw response by injecting an HttpClientInterface decorated with ScopingHttpClient or a small middleware logger; (3) reproduce with a one-off php artisan tinker transport call to separate app/queue problems from provider problems; (4) check account balance and sender-ID provisioning in the LightSms dashboard — those two account for most "the code looks right" failures.
Scope check before you commit. Zero dependents and a one-paragraph README mean community examples are essentially nonexistent; budget half a day for the first integration and rely on Symfony's symfony/notifier docs for anything the bridge doesn't explain. For high-volume or delivery-critical SMS, evaluate a native Laravel channel (laravel-notification-channels/*) with retries, webhooks, and delivery reports before adopting this bridge.
How can I help you explore Laravel packages today?