preflow / notify
Preflow notify — send email, SMS and other messages through pluggable transports
Requires
- php: >=8.4
Requires (Dev)
- phpunit/phpunit: ^11.0
- psr/log: ^3.0
- symfony/mailer: ^7.3 || ^8.0
- symfony/notifier: ^7.3 || ^8.0
Suggests
- psr/log: Logs deferred (sendLater) failures that have no onFailure callback
- 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
- preflow/core: <0.24
Replaces
None
- dev-main
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.2
- v0.19.1
- v0.19.0
- v0.18.1
- v0.18.0
- v0.17.2
- v0.17.1
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
This package is auto-updated.
Last update: 2026-11-11 00:52:51 UTC
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. - Any transport takes
redirect_to(an address, a comma-separated string or a list): every email it sends then goes only there, for stage and test installations. Cc and Bcc are dropped; the subject starts with[→ original recipients]and the body with a line naming To, Cc and Bcc. SMS passes through unchanged. Example:'redirect_to' => getenv('MAIL_REDIRECT_TO') ?: ''(empty means off).
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.
Sending after the response
Sending takes an SMTP round trip. On account flows (forgot password, registration, resend confirmation) that makes "an account exists" answer measurably slower than "no account", which leaks which addresses are registered. Queue those mails instead:
$notifier->sendLater( new Email(to: $user->email, subject: 'Reset your password', text: $text), onFailure: fn (\Throwable $e) => $this->audit->warn('reset mail failed'), );
sendLater() only queues. Core sends the queue once the response has gone out:
Application::run() emits the response, releases the client the way Symfony's
Response::send() does (fastcgi_finish_request() on PHP-FPM, else
litespeed_finish_request() on LiteSpeed LSAPI, else outside the CLI it flushes all output
buffers), then calls flushDeferred(). The flush sits in a finally, so mail queued before
an exception or an error page still goes out.
The "after the response" guarantee holds on PHP-FPM and LSAPI. Elsewhere (mod_php, plain
CGI) it is best effort: the connection stays open until the script ends. Some timing
differences remain regardless of the transport: building the message and any database
writes happen before the response. Keep those equally cheap on both branches of an account
flow. register_shutdown_function was not used: it runs outside the application's control
and cannot be flushed deterministically in the CLI and in tests.
- Order is FIFO; every message is isolated, one failure does not stop the others.
- A deferred failure never throws into the request. It calls
$onFailure(\Throwable)when given; otherwise it logs a generic line (no addresses) through a PSRLoggerInterfacebound in the container, elseerror_log(). flushDeferred()returns the number sent.- CLI and tests:
Application::handle()flushes the queue itself before returning (onlyrun()postpones it until after emitting), so in-process requests and tests never lose mail. A CLI command that does not go throughhandle()/run()must call$notifier->flushDeferred()itself. - The cap applies per
flushDeferred()call: it sends at mostNotifier::FLUSH_LIMIT(100) messages and logs what is left, so anonFailurethat keeps re-queuing cannot loop forever. IfonFailurethrows, a generic line is logged. - The logger is looked up in the container when a failure happens, so one bound by a
provider is used.
deferis read as a boolean ("false",0andoffcount as false). - Without
config/notify.phpthere is no Notifier and nothing happens. 'defer' => falseinconfig/notify.php(defaulttrue) makessendLater()send at once, a failure going to$onFailure/the log. Handy to debug; tests rarely need it.
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.