NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Auth for Laravel

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:

TypeClassRecipient
magic_linkMagicLinkNotificationaccount
email_otpEmailOtpNotificationaccount (login and re-authentication codes)
verify_emailVerifyEmailNotificationaccount
reset_passwordResetPasswordNotificationaccount
password_changedPasswordChangedNotificationaccount (change, reset, set)
email_change_confirmationConfirmEmailChangeNotificationthe new address
email_change_requestedEmailChangeRequestedNotificationthe old address
email_changedEmailChangedNotificationthe old address
account_existsAccountExistsNotificationan address already in use (the holder’s locale)
invitationInvitationNotificationinvitee (invitation locale)
new_deviceNewDeviceLoginNotificationaccount
two_factor_enabled / two_factor_disabledTwoFactorEnabledNotification / TwoFactorDisabledNotificationaccount
recovery_code_usedRecoveryCodeUsedNotificationaccount
passkey_added / passkey_removedPasskeyAddedNotification / PasskeyRemovedNotificationaccount
account_lockedAccountLockedNotificationaccount
refresh_token_reuseSuspiciousSessionNotificationaccount (refresh-token reuse; the session family was revoked)
sign_in_blockedSignInBlockedNotificationaccount (risk reaction deny)
unusual_sign_inUnusualSignInNotificationaccount (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.subjectYour :app password was changed by an administrator
password_changed.by_actor.introAn administrator changed the password of your account.
password_changed.by_actor.outroIf you did not expect this, contact support immediately.
two_factor_disabled.by_actor.subjectTwo-factor authentication disabled by an administrator
two_factor_disabled.by_actor.introAn administrator disabled two-factor authentication on your account.
two_factor_disabled.by_actor.outroIf you did not expect this, contact support immediately and turn two-factor authentication back on.
passkey_removed.by_actor.subjectAn administrator removed a passkey from your account
passkey_removed.by_actor.introAn administrator removed a passkey from your account. It can no longer be used to sign in.
passkey_removed.by_actor.outroIf you did not expect this, contact support immediately.
account_locked.by_actor.subjectYour :app account was locked by an administrator
account_locked.by_actor.introAn administrator temporarily locked your account. You cannot sign in until the lock ends.
account_locked.by_actor.outroIf 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-translations

The 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 crypto

By 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.