symfony/fake-sms-notifier
Fake SMS integration for Symfony Notifier that routes outgoing SMS as email or log messages during development. Configure via DSN with fakesms+email or fakesms+logger to test notifications without sending real texts.
Start with a decision, not an install. This is a Symfony Notifier bridge, not a Laravel package. There is no service provider, no via() channel, no facade. It only earns its place in a Laravel app if you are already running symfony/notifier (shared code with a Symfony app, a monorepo, or a deliberate multi-channel abstraction). If your only goal is "don't send real SMS in local," Laravel already ships the equivalent: the log notification channel, the mail channel, MAIL_MAILER=log, Mailpit, and Notification::fake(). Reach for this package when you want the same Notifier code path in dev and prod and just swap the transport.
Assuming you do want it:
composer require symfony/notifier symfony/fake-sms-notifier symfony/mailer
Pick the major by PHP version — v8.0 requires PHP >= 8.4, so most Laravel 10/11/12 apps want ^7.4. Pin to a patched release: >=6.4.30, >=7.3.8, or >=7.4.0, because of bug symfony/symfony#62406 (recipient phone numbers were being used as sender e-mail addresses). Also prefer releases containing #60885 (fake SMS transports moved to the contracts event dispatcher).
Look at these three things first, in order:
fakesms+email and fakesms+logger.vendor/symfony/fake-sms-notifier/FakeSmsTransportFactory.php — read the constructor; it tells you which services you must supply (mailer, logger, dispatcher). This is the whole integration surface..env / config/services.php, where the DSN will live.First use case — get a code out of your app without Twilio. Add to .env.local:
FAKE_SMS_DSN=fakesms+logger://default
and later, once you've wired a mailer, fakesms+email://[email protected]&[email protected]. The logger variant is the zero-friction option: it needs nothing but a PSR-3 logger, and the message body shows up in storage/logs/laravel.log.
Minimal working binding (Laravel 11/12 AppServiceProvider::register()):
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Notifier\Bridge\FakeSms\FakeSmsTransportFactory;
use Symfony\Component\Notifier\Transport\Dsn;
$this->app->singleton('sms.transport', function ($app) {
$factory = new FakeSmsTransportFactory(
dispatcher: $app->make(\Psr\EventDispatcher\EventDispatcherInterface::class),
mailer: new Mailer(Transport::fromDsn('smtp://127.0.0.1:1025')), // Mailpit
logger: $app->make(\Psr\Log\LoggerInterface::class),
);
return $factory->create(Dsn::fromString(config('services.fake_sms.dsn')));
});
Then app('sms.transport')->send(new \Symfony\Component\Notifier\Message\SmsMessage('+15551234567', 'Your code is 1234'));.
Wrap it behind one interface. Define App\Sms\SmsSender with send(string $phone, string $body): void, and bind either a FakeSmsSender (the Symfony transport) or a TwilioSmsSender. Your domain code never learns which is active, and swapping drivers never touches a notification class.
Per-environment DSN, one config key. Use FAKE_SMS_DSN in .env.local and .env.testing, a real provider DSN in production, and resolve both through a single config/services.php entry. This is the cleanest Laravel shape because it matches how you already handle MAIL_MAILER and DB_CONNECTION.
Use Transports if you need both, live. When you want to toggle at runtime rather than by env, register two transports and pick one explicitly:
use Symfony\Component\Notifier\Transport\Transports;
$transports = new Transports([
'fakesms' => $fakeTransport,
'twilio' => $realTransport,
]);
$transports->send($smsMessage, $request->boolean('use_fake') ? 'fakesms' : 'twilio');
This is the pattern that makes "send myself a test SMS" a feature rather than a config deploy.
Bridging to Laravel Notifications. If your notifications already go through via(), write a thin custom channel rather than rewriting everything:
class FakeSmsChannel
{
public function __construct(private SmsSender $sms) {}
public function send($notifiable, Notification $notification): void
{
$this->sms->send($notifiable->routeNotificationFor('sms'), $notification->toSms($notifiable));
}
}
Gate it in via() on app()->environment('local', 'testing'). Keep Notification::fake() in tests — it asserts on the notification, not the transport, so it composes fine with this.
Testing assertions. With fakesms+logger, assert on the log channel (Log::spy() plus shouldHaveReceived('info'), or a dedicated fakesms log file you tail). With fakesms+email, assert against Mailpit's API or swap in Symfony's NullTransport and inspect via an event listener. Do not try to use Laravel's Mail::fake() — see the gotcha below.
The honest fallback table. For most Laravel teams, the day-to-day answer is: log channel for "what would I have sent," Mailpit + mail channel for "how would it look," Notification::fake() for assertions. Use this package only when the Notifier abstraction is already load-bearing.
Laravel's Mailer is not Symfony's Mailer. FakeSmsTransportFactory wants Symfony\Component\Mailer\MailerInterface. Laravel binds Illuminate\Mail\Mailer. There is no automatic bridge, so Mail::fake() intercepts nothing, and MAIL_MAILER doesn't reach this transport. You must build a Symfony Mailer yourself from a Symfony DSN (pointing at Mailpit or smtp://). This single mismatch is where most integration attempts die; budget for it before you promise the feature.
Two event dispatchers. Newer releases use the PSR-14 contracts dispatcher (bug #60885), which Laravel's Illuminate\Events\Dispatcher satisfies — convenient. But if anything pins symfony/event-dispatcher, you get a second dispatcher and Notifier's MessageEvent never reaches Laravel listeners. Never assume Notifier events are observable from Laravel.
Missing to/from fails at boot, not at send. Both are required options for the email scheme and throw an IncompleteDsnException when the factory parses the DSN. That's good — fail fast — but it means a typo in .env takes down the container, not one request. Add a config test that asserts the DSN parses.
Pin past #62406. Below 6.4.30 / 7.3.8 / 7.4.0 / 8.0.0, the transport set the sender address to the recipient's phone number. You get invalid From: headers: mail bounced, spam-filtered, or silently dropped — and it looks like your code is broken. Check composer show symfony/fake-sms-notifier before debugging anything else.
Keep the host segment as default unless you genuinely run named mailer transports; the README's mailchimp example assumes a multi-transport mailer setup you probably don't have.
Don't let fake SMS land in a shared inbox. The phone number and message body are in the email — that's PII in a company-wide address. Mailpit locally, a dedicated log channel in CI, never a distribution list.
Production guard. Add an explicit check in a service provider: if app()->isProduction() and the fake DSN is configured, throw. Silent fake SMS in production is indistinguishable from a broken gateway until a customer complains they never got their OTP.
No delivery semantics. No rate limiting, no retries, no provider callbacks, getMessageId() is synthetic. It's a dev tool — don't write code that depends on its response shape, or you'll couple production behavior to a fixture.
Extension point worth knowing: subclass the transport and override doSend() to also push into an in-memory store. That gives you assertSmsSentTo('+1555…') semantics inside Pest/PHPUnit without parsing logs or mailboxes.
How can I help you explore Laravel packages today?