Search by

polaris / messaging

tonydspaniard

Messaging for Polaris for PHP: templated, translated, rate-limited email and SMS through pluggable channels, every send audited

v0.11.0 2026-10-13 21:28 UTC

This package is auto-updated.

Last update: 2026-10-13 21:31:04 UTC


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.