polaris/messaging
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/messaginguse 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, '[email protected]', $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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “License”MIT. Polaris for PHP is created and maintained by 2am.tech.