Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Light Sms Notifier Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

1. Install the bridge and the Notifier stack

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

2. Configure the DSN

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
],

3. Bind the transport in a service provider

// 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')));
    });
}

4. First use case: an OTP

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.

Where to look first

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.


Implementation Patterns

Pattern A — raw transport call (simplest, do this first)

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.

Pattern B — a Laravel notification channel (the day-to-day pattern)

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.

Pattern C — Symfony 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).

Pattern D — multiple senders / per-tenant from

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

Pattern E — testing without hitting LightSms

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.

Integration tips

  • Always queue. Wrap the send in a job (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.
  • Rate-limit with RateLimiter::for('sms', fn ($job) => Limit::perMinute(30)) — carriers and LightSms accounts both have throughput ceilings.
  • Log SentMessage::getMessageId() and store it against the user/order. It's the only correlation ID you get back.
  • Prefer a Laravel 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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log