The first four parts showed you what Polaris does out of the box. This one is about making it yours: sending real text messages and emails, screening passwords against breach corpora, and reacting to everything that happens inside the module without forking a single line of it.
The ports philosophy
I refused to bake a vendor SDK into the core. If Polaris hard-depended on Twilio, everyone who uses Vonage or Amazon SNS or a plain SMTP relay would be carrying dead weight. So delivery is a port. Polaris ships dev drivers that just log the code, so the flows work out of the box on day one, and you bind a real adapter in production.
The contracts are tiny. Here is the SMS one in full:
interface SmsSenderInterface
{
/**
* @param non-empty-string $toE164 destination phone in E.164 form, e.g. +14155550101
*/
public function send(string $toE164, string $message): void;
}A Twilio adapter is maybe fifteen lines:
final readonly class TwilioSmsSender implements SmsSenderInterface
{
public function __construct(
private \Twilio\Rest\Client $twilio,
private string $fromNumber,
) {
}
public function send(string $toE164, string $message): void
{
$this->twilio->messages->create($toE164, [
'from' => $this->fromNumber,
'body' => $message,
]);
}
}The email port follows the same idea, with a logical template name and a context array so your adapter owns the rendering:
interface OtpMailerInterface
{
/**
* @param array<string, mixed> $context template variables (code, ttl, app name, ...)
*/
public function send(string $toEmail, string $template, array $context): void;
}final readonly class SesOtpMailer implements OtpMailerInterface
{
public function __construct(private \Aws\Ses\SesClient $ses, private string $from)
{
}
public function send(string $toEmail, string $template, array $context): void
{
$html = $this->renderTemplate($template, $context); // your renderer of choice
$this->ses->sendEmail([
'Source' => $this->from,
'Destination' => ['ToAddresses' => [$toEmail]],
'Message' => [
'Subject' => ['Data' => 'Your verification code'],
'Body' => ['Html' => ['Data' => $html]],
],
]);
}
}You bind these in your host container after registering the module, and Polaris picks them up automatically (it only binds its own dev drivers when a binding is absent):
$container->singleton(SmsSenderInterface::class, TwilioSmsSender::class);
$container->singleton(OtpMailerInterface::class, SesOtpMailer::class);The breached-password port, and a rule you must respect
There is a third port worth knowing: BreachedPasswordCheckInterface. Polaris ships an adapter that screens new passwords against the Have I Been Pwned corpus using k-anonymity. It only ever sends the first five characters of a hash prefix, never the password or its full hash. The contract has one rule that is easy to get wrong:
interface BreachedPasswordCheckInterface
{
public function isBreached(#[SensitiveParameter] string $password): bool;
}Implementations must be fail-open. If the upstream source is down, you return false and let the signup through. You do not block paying customers from creating accounts because a third party had a bad afternoon. Availability of your product is not held hostage to an optional security nicety. Turn the check on with auth.password.breach_check once you have an adapter bound.
Extending the permission catalog
One more port, this time for part 4's RBAC. If your host app needs its own capability keys, say billing.manage, implement PermissionContributorInterface:
interface PermissionContributorInterface
{
/**
* @return array<string, string> map of permission key (resource.action) to description
*/
public function permissions(): array;
}Register it and the catalog merges your keys in; the seed migration writes them to the permissions table, and they become attachable to roles like any built-in key.
The event stream
Every meaningful action emits a PSR-14 event on the framework's dispatcher. This is how you wire side effects (welcome emails, audit trails, analytics, Slack pings) without touching Polaris at all. There are around thirty-five of them, all immutable readonly DTOs, with dotted past-tense names like user.registered and auth.refresh_reuse_detected.
Here is a real one. Notice the care taken with the secret it carries:
final readonly class UserRegistered
{
public const string NAME = 'user.registered';
public function __construct(
public string $userId,
public string $email,
#[SensitiveParameter] public string $verificationToken,
) {
}
public function __debugInfo(): array
{
return [
'userId' => $this->userId,
'email' => $this->email,
'verificationToken' => '[redacted]',
];
}
}That __debugInfo() override means that if anyone ever var_dumps this event, the verification token shows up as [redacted]. The #[SensitiveParameter] attribute already keeps it out of stack traces, but dumping is a separate hole, so I closed it too. A handful of events (registration, password reset, member invite) carry a one-time token precisely so the notification listener can deliver it, and the audit listener has an explicit whitelist that deliberately never records those fields.
This is also the seam where the channels from part 3 and the invitations from part 4 actually get delivered: the notification listener subscribes to these token-bearing events and calls your SmsSenderInterface / OtpMailerInterface adapters.
Writing a listener
A listener is just a class:
final readonly class SendWelcomeEmail
{
public function __construct(private Mailer $mailer)
{
}
public function __invoke(UserRegistered $event): void
{
$this->mailer->send(
$event->email,
'verify-your-email',
['token' => $event->verificationToken],
);
}
}Register it against UserRegistered::NAME in your listeners config, and you are done. Events are dispatched after the domain transaction commits, so your listener never fires on state that got rolled back. The one exception is the *_failed events, which are informational by nature.
A few events you will probably want to subscribe to first:
user.registered,user.password_reset_requested, andmember.invited: the token-bearing ones, for delivery.auth.refresh_reuse_detected: the refresh-token theft signal from part 2. Wire this to alerting.user.login_failedanduser.locked: for fraud monitoring.org.created,member.joined,member.removed: for your own onboarding and billing flows.
The full catalog with every payload is in the reference docs.
That is the series
Five parts, one module, one line of config:
- Meet Polaris: the what and the why.
- Logins that do not leak: registration, verification, JWTs, rotating refresh.
- Real MFA in an afternoon: TOTP, SMS, email, recovery, step-up.
- One user, many orgs: organizations, roles, the Gate, the invariants.
- Providers and events: the part you just read.
Polaris is the proof, for me at least, that the framework revival from the Univeros origin post was worth it. The agents are fast hands; the judgment is still mine; and the result is something I would actually ship.
composer require univeros/polarisThe framework is at univeros.io, the docs at polaris.univeros.io, and the source on GitHub. If there is something you want me to dig into next, the issues tab is open and I read everything. Go build something that does not leak.
Build captivating apps and sophisticated B2B platforms
Stunning solutions for web, mobile, or cross-platform applications.
Learn more.jpg)