Skip to content

Domain Events

Polaris emits PSR-14 events on the framework’s Happen dispatcher for every significant identity action. Events decouple side effects (sending emails/SMS, writing the audit log, host integrations) from the domain services that raise them. A host subscribes listeners via listeners:list / its config; Polaris ships the audit-log, metrics, and notification listeners and lets hosts add their own.

Every event class lives in Univeros\Polaris\Event\ and exposes its dotted name as a NAME constant. The payload columns below are the constructor properties, in order.

  • Event names are dotted, past-tense: resource.action.
  • Each event is an immutable readonly DTO carrying ids and non-secret context only. Events that participate in a secret-bearing flow (user.registered, user.password_reset_requested, member.invited) do carry the one-time token so the notification listener can send it, but the audit listener never writes it: the audit metadata is an explicit whitelist.
  • Events are dispatched after the domain transaction commits (so listeners never act on rolled-back state), except *_failed events, which are informational.
  • ip and ua (user agent) come from the request’s ClientContext. The user agent is sanitized and bounded at the edge by ClientContextMiddleware.

EventPayload (ids + context)Typical listeners
user.registereduserId, email, verificationToken*send verification, audit
user.email_verifieduserId, emailaudit
user.logged_inuserId, sid, ip, ua, amraudit
user.login_faileduserId, ip, ua, reasonaudit, alerting
user.lockeduserId, ip, untilaudit, alerting, notify user
user.password_changeduserId, method (reset|change)audit, notify user
user.password_reset_requesteduserId, email, resetToken*send reset, audit
user.disabled / user.enableduserId, actorUserIdaudit
user.deleteduserId (tombstone), actorUserIdaudit

* secret: delivered by the notification listener, never written to the audit log.

Notes:

  • user.logged_in fires once per completed login: on the password-only path, or after the MFA gate clears. amr records the methods (["pwd"] or ["pwd","otp"]). It carries no org context; a session is scoped to an org later via auth.org_switched.
  • user.login_failed fires only for a known, active, not-currently-locked account (no account-existence oracle). reason is currently always invalid_credentials.
  • user.locked carries until, the lock expiry instant.
EventPayloadListeners
auth.token_refresheduserId, familyIdaudit (debug)
auth.refresh_reuse_detecteduserId, familyId, ip, uaaudit + alerting
auth.sessions_revokeduserId, ip, count, reasonaudit
auth.org_switcheduserId, fromOrgId, toOrgIdaudit

Notes:

  • familyId is the session id (the sid claim): one refresh-token family is one session.
  • auth.sessions_revoked is emitted by logout-all, with count (revoked sessions) and reason. The password reset/change flows also revoke sessions (logout everywhere) but emit only user.password_changed; subscribe to that event when you need to observe credential-change revocations.
EventPayloadListeners
mfa.enrolleduserId, factorId, typeaudit, notify user
mfa.factor_removeduserId, factorIdaudit, notify user
mfa.verifieduserId, factorId (null for recovery)audit
mfa.verify_faileduserId, factorId, typeaudit, alerting
mfa.step_up_completeduserId, sessionIdaudit
otp.sentuserId, factorId, channelaudit (rate-watch)
otp.verify_faileduserId, factorId, attemptsLeftaudit, alerting
mfa.recovery_regenerateduserIdaudit, notify user
mfa.recovery_useduserId, remainingaudit, notify user

Notes:

  • mfa.enrolled fires when the user confirms their first factor (the moment MFA starts protecting the account), with the factor type (totp/sms/email).
  • mfa.verify_failed is the gate-level signal (login MFA and step-up): it carries the attempted factorId and its type; type is recovery on the factor-less recovery-code path and null for an unknown or unconfirmed factor (the audit row discloses nothing the generic API failure does not).
  • otp.verify_failed is the channel-level signal for sms/email codes and carries attemptsLeft, the remaining attempt budget for the live challenge.
  • A recovery batch is always RecoveryCodeService::COUNT (10) codes, so mfa.recovery_regenerated needs no count.
EventPayloadListeners
org.createdorgId, slug, ownerUserIdaudit
org.updatedorgId, actorUserIdaudit
org.deletedorgId, actorUserIdaudit, alerting
member.invitedorgId, email, invitedBy, inviteToken*send invite, audit
member.joinedorgId, userId, emailaudit
member.roles_changedorgId, userId, roleSlugs, actorUserIdaudit
member.status_changedorgId, userId, status, actorUserIdaudit
member.removedorgId, userId, actorUserIdaudit
role.createdorgId, roleId, actorUserIdaudit
role.updatedorgId, roleId, actorUserIdaudit
role.deletedorgId, roleId, actorUserIdaudit

* secret: delivered by the notification listener, never written to the audit log.

Notes:

  • member.status_changed covers suspension and reactivation; status is suspended or active.
  • org.deleted is the soft delete (status=suspended); it is idempotent and fires once.

// Polaris ships these; hosts may add more.
bin/altair listeners:list --format=json # inspect what's subscribed
bin/altair listeners:show auth.refresh_reuse_detected
  • AuditLogListener writes one append-only auth_audit_log row per event: the event name, the actor and org context, ip/user_agent where the event carries them, and a whitelisted metadata blob. Unknown events are ignored, and a failed audit write is logged and swallowed (fail-open: audit loss must not break the user-facing operation).
  • MetricsListener increments the polaris.auth.events counter for every Polaris event, with the event name as an attribute.
  • NotificationListener subscribes to the user-facing ones (user.registered, user.password_reset_requested, member.invited, user.locked, the mfa.* lifecycle events) and calls the OtpMailerInterface / SmsSenderInterface ports.
  • Hosts add listeners (e.g. push user.registered to a CRM, alert on auth.refresh_reuse_detected) without touching Polaris.

The notification content/templates are the host’s concern via the OtpMailerInterface template name + context; Polaris defines the event and the data, not the copy.