Notifications
All notifications extend AuthenticationNotification and are sent by mail. Swap one per guard with notifications.classes.<type> (a subclass of AuthenticationNotification), or set it to null to disable it:
| Type | Class | Recipient |
|---|---|---|
magic_link | MagicLinkNotification | account |
email_otp | EmailOtpNotification | account (login and re-authentication codes) |
verify_email | VerifyEmailNotification | account |
reset_password | ResetPasswordNotification | account |
password_changed | PasswordChangedNotification | account (change, reset, set) |
email_change_confirmation | ConfirmEmailChangeNotification | the new address |
email_change_requested | EmailChangeRequestedNotification | the old address |
email_changed | EmailChangedNotification | the old address |
account_exists | AccountExistsNotification | an address already in use (the holder’s locale) |
invitation | InvitationNotification | invitee (invitation locale) |
new_device | NewDeviceLoginNotification | account |
two_factor_enabled / two_factor_disabled | TwoFactorEnabledNotification / TwoFactorDisabledNotification | account |
recovery_code_used | RecoveryCodeUsedNotification | account |
passkey_added / passkey_removed | PasskeyAddedNotification / PasskeyRemovedNotification | account |
account_locked | AccountLockedNotification | account |
refresh_token_reuse | SuspiciousSessionNotification | account (refresh-token reuse; the session family was revoked) |
sign_in_blocked | SignInBlockedNotification | account (risk reaction deny) |
unusual_sign_in | UnusualSignInNotification | account (risk reaction notify) |
Risk alerts
Risk reactions have their own mails. deny sends SignInBlockedNotification (sign_in_blocked) — nobody was signed in; notify sends UnusualSignInNotification (unusual_sign_in) once the sign-in completes — its session is live. Neither says a session was signed out: SuspiciousSessionNotification (refresh_token_reuse) covers only refresh-token reuse, so change or silence the risk alerts through sign_in_blocked and unusual_sign_in. Both go out whenever the guard’s risk.assessor returns a level whose reaction is deny or notify (see Activity & risk). A published config that predates them — it lacks their notifications.classes keys — still sends them; set a key to null to switch one off:
'guards' => [
'users' => [
'model' => App\Models\User::class,
'risk' => [
'assessor' => NewDeviceRisk::class,
'reactions' => ['elevated' => 'notify', 'high' => 'deny'],
],
'notifications' => [
'classes' => [
'sign_in_blocked' => App\Notifications\SignInBlocked::class, // extends AuthenticationNotification
'unusual_sign_in' => null, // null switches it off
],
],
],
],When an admin acted
Four types can be caused by someone other than the account, through host code: password_changed (passwords()->set()), two_factor_disabled (twoFactor()->disable()), passkey_removed (passkeys()->remove()) and account_locked (lock()) — NotificationType::actorAware() is true for exactly these. Pass the admin as actor: and:
- NotificationData::$actor holds an ActorData — the actor’s morph type and key, never the model, because the encrypted delivery job serializes the notification and an admin’s attributes (password hash, 2FA secret) do not belong in the queue store or failed_jobs. ActorData::fromModel() builds it (an unsaved model throws InvalidArgumentException), is($model) compares and resolve() loads the model again — null once it is gone.
- toMail() takes the subject, intro and outro from notifications.<type>.by_actor.*; the action, code and expiry lines stay as they are. The copy says “an administrator” and never names the actor.
- The event (PasswordChanged, TwoFactorDisabled, PasskeyRemoved, AccountLocked) carries the actor model as actor.
The account itself is no actor, so the user’s own flows, the HTTP endpoints and the automatic lockout always send the base copy; without actor: nothing changes, and every other type ignores an actor. The new lines ship in English and Slovak:
| Key notifications.… | en |
|---|---|
password_changed.by_actor.subject | Your :app password was changed by an administrator |
password_changed.by_actor.intro | An administrator changed the password of your account. |
password_changed.by_actor.outro | If you did not expect this, contact support immediately. |
two_factor_disabled.by_actor.subject | Two-factor authentication disabled by an administrator |
two_factor_disabled.by_actor.intro | An administrator disabled two-factor authentication on your account. |
two_factor_disabled.by_actor.outro | If you did not expect this, contact support immediately and turn two-factor authentication back on. |
passkey_removed.by_actor.subject | An administrator removed a passkey from your account |
passkey_removed.by_actor.intro | An administrator removed a passkey from your account. It can no longer be used to sign in. |
passkey_removed.by_actor.outro | If you did not expect this, contact support immediately. |
account_locked.by_actor.subject | Your :app account was locked by an administrator |
account_locked.by_actor.intro | An administrator temporarily locked your account. You cannot sign in until the lock ends. |
account_locked.by_actor.outro | If you did not expect this, contact support. |
A host that published the translations before these keys existed still gets them — Laravel merges a published file over the package’s lines; a locale the package does not ship falls back to app.fallback_locale until you add them. To phrase them differently, override authentication::notifications.<type>.by_actor.* in the published translations. To name the actor, swap the class (notifications.classes.password_changed) and resolve it:
use Illuminate\Notifications\Messages\MailMessage;
use RoundlyConsulting\Auth\Notifications\PasswordChangedNotification;
final class NamedPasswordChangedNotification extends PasswordChangedNotification
{
public function toMail(object $notifiable): MailMessage
{
$mail = parent::toMail($notifiable);
$admin = $this->data->actor?->resolve(); // null without an actor, or when it was deleted
return $admin === null ? $mail : $mail->line(__('Changed by :name.', ['name' => $admin->name]));
}
}Delivery
- after_response (default) — a known account’s response is not measurably slower than an unknown one’s.
- queue — through an encrypted job (ShouldBeEncrypted) on notifications.connection / queue, so the plaintext link or code never sits readable in the queue store.
- sync — inline, for development.
Links
Emailed links are rendered from config only (no client-supplied redirect) with {frontend}, {app}, {guard}, {email} and {token}. The defaults put the secret in the URL fragment (#token=…), so it never reaches a server log or a Referer header — the frontend reads location.hash and POSTs the token. Do not use the log mail driver outside development.
'guards' => [
'users' => [
'model' => App\Models\User::class,
'notifications' => [
'delivery' => 'queue', // sync|after_response|queue
'queue' => 'mail',
'classes' => [
'magic_link' => App\Notifications\MagicLink::class, // extends AuthenticationNotification
'new_device' => null, // null disables it
],
'frontend_url' => 'https://app.example.com',
'urls' => [
'magic_link' => '{frontend}/auth/magic-link?guard={guard}#token={token}',
],
],
],
],Copy and language
Copy lives in authentication::notifications.* and ships in English and Slovak (en, sk); other locales fall back to app.fallback_locale until you add them under lang/vendor/authentication/<locale>/. Publish the translations to change or add copy. Accounts get mail in their locale via preferredLocale():
php artisan vendor:publish --tag=authentication-translationsThe expiry line is rendered with trans_choice() on the minutes left, so it carries plural forms (a published override without | still works), and the refresh-token-reuse :reason renders from notifications.refresh_token_reuse.reasons.* in the mail’s language.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.