preflow / notify
Preflow notify — send email, SMS and other messages through pluggable transports
Requires
- php: >=8.4
Requires (Dev)
- phpunit/phpunit: ^11.0
- symfony/mailer: ^7.3 || ^8.0
- symfony/notifier: ^7.3 || ^8.0
Suggests
- symfony/mailer: Enables the symfony-mailer transport (SMTP and all Symfony mailer bridges)
- symfony/notifier: Enables the symfony-notifier-sms transport (Twilio, Vonage and other Symfony SMS bridges)
Provides
None
Conflicts
None
Replaces
None
README
Send email, SMS and other messages through pluggable transports. Application code
depends on one class, Notifier, and on small message value objects. Which provider
delivers a channel is configuration.
use Preflow\Notify\Email; use Preflow\Notify\Notifier; use Preflow\Notify\Sms; $notifier->send(new Email(to: 'me@example.com', subject: 'Hi', text: 'Hello there')); $notifier->send(new Sms(to: '+41790000000', text: 'Your code: 4711'));
Notifier is injected by constructor like any other service. Sending is synchronous.
This is not a message bus or queue (unlike Symfony Messenger): it only delivers
outbound messages.
Configuration
Core boots the package when config/notify.php exists:
return [ 'transports' => [ 'smtp' => ['driver' => 'symfony-mailer', 'dsn' => getenv('MAILER_DSN') ?: ''], 'log' => ['driver' => 'log'], 'mail' => ['driver' => 'failover', 'transports' => ['smtp', 'log']], ], 'channels' => [ 'email' => ['transport' => 'mail', 'from' => getenv('MAIL_FROM') ?: null], 'sms' => ['transport' => 'log'], ], ];
- Credentials belong in
.env. - Transports are created on first send, so an empty or broken DSN never breaks boot. It fails, with a clear message, when something is sent.
- A channel's
fromis used when a message doesn't set its own.
Drivers
| Driver | Needs | Purpose |
|---|---|---|
log |
nothing | Appends a summary and the body to storage/logs/messages.log (or path). |
null |
nothing | Drops messages. |
memory |
nothing | Keeps messages in memory (sent(), reset()) for tests. |
failover |
nothing | Tries transports in order. Skips any that can't send the message type. |
symfony-mailer |
composer require symfony/mailer |
dsn: smtp://user:pass@host:587 or any Symfony mailer bridge DSN. |
symfony-notifier-sms |
composer require symfony/notifier plus a bridge (e.g. symfony/twilio-notifier) |
dsn: e.g. twilio://SID:TOKEN@default?from=+41…. |
Messages
Email:to,subject,text, plus optionalhtml,from,replyTo,cc,bccandattachments(Attachment::fromPath()/Attachment::fromContents()).- Addresses are strings:
a@b.testorName <a@b.test>. - Line breaks in the subject or addresses are rejected, which prevents header injection.
Sms:toin E.164 format (+41790000000),text, optionalfrom.- Your own types: implement
Preflow\Notify\Messagewith a channel key (chat,push, …) and configure a transport that supports it.
A provider with its own SDK
Implement Preflow\Notify\Transport and register it as a driver from a service provider:
final class AcmeSmsTransport implements Transport { public function __construct(private AcmeClient $client, private string $name) {} public function supports(Message $message): bool { return $message instanceof Sms; } public function send(Message $message): SentMessage { try { $id = $this->client->sendText($message->to, $message->text); } catch (\Throwable $e) { throw TransportException::fromProvider($this->name, $e); } return new SentMessage($message, $this->name, $id); } } // in a ServiceProvider::boot() $container->get(TransportFactory::class)->extend( 'acme-sms', fn (array $config, string $name) => new AcmeSmsTransport(new AcmeClient($config['key']), $name), );
Then set 'texts' => ['driver' => 'acme-sms', 'key' => getenv('ACME_KEY') ?: ''] in
transports.
Errors
Every failure is a Preflow\Notify\TransportException. Provider errors are wrapped, and
their message is masked: URL user-info (smtp://***@host) and secret-looking query
parameters (api_key=***, password=***, token=***). The original exception is chained
unmasked as getPrevious(), so don't log the whole chain verbatim.
When a failover delivers through a fallback, SentMessage::$failures lists each transport
it fell back from (name → masked reason). Log these. Otherwise a broken SMTP setup goes
unnoticed while mail quietly lands in the log transport.
The caller decides what a failure means; for a contact form, store the submission first, then send.
CLI
php preflow notify:test --to=me@example.com php preflow notify:test --channel=sms --to=+41790000000
This sends a fixed test message through the configured transport and prints the result or the error. It exits non-zero when a failover had to fall back, and prints why the preferred transport failed. Use it to check credentials on a live server.