polaris / messaging
Messaging for Polaris for PHP: templated, translated, rate-limited email and SMS through pluggable channels, every send audited
Requires
- php: >=8.3
- polaris/audit: ^0.11
- polaris/cli: ^0.11
- polaris/core: ^0.11
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^3.0
- symfony/console: ^7.0 || ^8.0
- symfony/uid: ^7.0 || ^8.0
Requires (Dev)
None
Suggests
- phpmailer/phpmailer: For PhpMailerChannel
- symfony/mailer: For SymfonyMailerChannel
Provides
None
Conflicts
None
Replaces
None
README
Messaging for Polaris for PHP: the verification, reset, invitation, one-time-code and security messages rendered from templates you can override per instance and per organization, translated (en, es, de, fr, pt bundled), rate-limited per recipient, sent through the channel you choose (Symfony Mailer, PHPMailer, Twilio, Vonage, a log, an array in tests), every send recorded in the audit store without its body.
composer require polaris/messaging
use Polaris\Audit\AuditPlugin; use Polaris\Messaging\Channel\SymfonyMailerChannel; use Polaris\Messaging\Channel\TwilioSmsChannel; use Polaris\Messaging\MessagingPlugin; $polaris = Polaris::create(new Config( ..., // leave `mailer` and `sms` unset: the plugin provides them plugins: [new AuditPlugin(), new MessagingPlugin( channels: [new SymfonyMailerChannel($mailer, 'noreply@example.com', $clock), new TwilioSmsChannel($psr18, $psr17, $psr17, $clock, $sid, $token, $from)], locale: 'en', templates: ['en' => ['email.verify' => ['subject' => 'Welcome to Acme', 'text' => 'Verify at https://acme.example/verify/{token}']]], caps: ['*' => [5, 600], 'sms.otp' => [3, 600]], // per recipient and template: [limit, window seconds] )], ));
polaris/audit is required and registered too: every delivery is a messaging.sent event. In Laravel,
Symfony and Yii the instance goes in the adapter's plugins configuration with the adapter's mailer and
sms left unset (log), so the plugin takes over both ports.
How it reaches core
Core sends through two ports, OtpMailerInterface and SmsSenderInterface. When the configuration leaves
them unset, the graph takes a plugin's implementation: this plugin's Bridge\Mailer renders core's
templates (verify_email, password_reset, org_invite, otp_code, account_locked,
password_changed, mfa_enrolled, mfa_factor_removed, recovery_codes_regenerated,
recovery_code_used) from the keys below and sends them as email; Bridge\Sms sends core's code message
as sms.otp. A host that keeps its own mailer keeps it; the plugin's Sender is still there for the
application's own messages:
$graph->get(Sender::class)->send('email', $user->email, 'email.new_device', ['ip' => $ip, 'user_agent' => $ua], locale: 'es', organizationId: $orgId);
Templates and translations
Keys: email.verify, email.reset_password, email.magic_link, email.otp, email.invite,
email.new_device, email.password_changed, email.account_locked, email.mfa_enrolled,
email.mfa_factor_removed, email.recovery_codes_regenerated, email.recovery_code_used, sms.otp,
sms.new_device. Each is a subject (email), a text and an optional HTML with {placeholders}
({token}, {code}, {ttl}, {link}, {ip}, {user_agent}, {method}, {remaining}), escaped in the
HTML. Resolution: the organization's override (polaris_messaging_template, written with
Templates::set()), then the instance's (templates:), then the bundled strings of the locale, then of the
default locale. Strings come from a Translator: the bundled ArrayTranslator (resources/translations)
takes the host's additions (strings:), or the host plugs its own.
Policy and channels
MessagePolicy: a cap per recipient and template (or kind, or *) through core's rate store; a fallback
kind (sms → email) used when no channel of the first kind delivered and the send named an alternative
recipient; quiet mode through a Suppressor (the one passed to the plugin, or the one another package provides
through its services, as polaris/sentinel does) that drops non-essential messages. Channels are tried in order for the message's kind; a channel throws DeliveryException and the
next is tried; a refused or undelivered message is logged and never breaks the operation that asked for
it. polaris messaging:send <to> <template> --vars='{"code":"123456"}' checks a channel from the console.
Sending from a queue
Outbox is where a send goes once the policy allowed it. SyncOutbox, the default, delivers on the
request. To take sends off the request path, pass your own: it receives data (the Message, scalars and
arrays only, and the alternative recipients of its fallback), puts it on your queue and answers
Receipt::queued(); the worker hands the same data to Sender::dispatch(), which delivers through the
channels and the fallback without asking the policy again or coming back to the outbox:
use Polaris\Messaging\{Message, MessagingPlugin, Outbox, Receipt, Sender}; final class QueueOutbox implements Outbox { public function __construct(private Queue $queue) {} public function push(Message $message, array $alternatives): ?Receipt { $this->queue->publish(serialize([$message, $alternatives])); return Receipt::queued(new DateTimeImmutable()); } } new MessagingPlugin(channels: [$mail, $sms], outbox: new QueueOutbox($queue)); // the worker, on the same graph: [$message, $alternatives] = unserialize($job, ['allowed_classes' => [Message::class]]); $polaris->graph()->get(Sender::class)->dispatch($message, $alternatives);
A queued send has no channel receipt at request time: Sender::send() answers the queued receipt
($receipt->queued is true), null only when the policy refused the send or no channel delivered it. The
messaging.sent audit record is written by the worker, when a channel takes the message.
License
MIT. Polaris for PHP is created and maintained by 2am.tech.