Skip to main content

Event reference

Every event is a final readonly class with public promoted properties, and every one carries the Request, so a listener can record the IP address and user agent.

A queued listener receives the event without its request. A listener that implements ShouldQueue gets the event through the queue, and a request cannot be serialized, so the request property is null there. Read what you need from the request in a synchronous listener, or pass it on yourself. Until 0.31.0 such a listener made the dispatch throw, and the request endpoint answered a known address with a 500 while an unknown one got the redirect.

Events are observability only — they must not drive flow control. Login-versus-two-factor is decided by the MagicLinkAuthenticator contract, which returns a response; see Extension points.

MagicLinkRequested​

EmailMagicLink\Events\MagicLinkRequested(
Authenticatable $user,
string $channel, // 'link' or 'code'
?Request $request,
)

The request endpoint issued a link or code for a known user. It does not fire for a submitted address that resolves to no user — that request is answered identically, which is what keeps the endpoint enumeration-resistant. It does not fire for a credential minted through issueLink() or issueCode() either; record those where you mint them.

MagicLinkVerified​

EmailMagicLink\Events\MagicLinkVerified(
Authenticatable $user,
?Request $request,
)

A token was verified and consumed, before the authenticator runs. A verified token is not yet a completed login — a user whose two-factor authentication Fortify counts as enabled goes to the challenge from here.

MagicLinkAuthenticated​

EmailMagicLink\Events\MagicLinkAuthenticated(
Authenticatable $user,
string $guard,
?Request $request,
)

The user was actually logged in. It fires only on a completed login, never for a two-factor handoff, so it never records a sign-in that did not happen. Successful logins also fire Laravel's own Illuminate\Auth\Events\Login.

A two-factor user's sign-in completes inside Fortify, which fires Login but not this event. An audit log that should cover those sign-ins too listens for TwoFactorChallengeRequired and Login as well.

MagicLinkConsumptionFailed​

EmailMagicLink\Events\MagicLinkConsumptionFailed(
ClaimFailure $reason,
?Request $request,
)

A consume attempt failed. The HTTP response stays generic and enumeration-resistant regardless of which reason fired — the event is where the detail lives, so you can log every failure and alert specifically on LockedOut (a brute-force lockout) or repeated InvalidCode.

EmailMagicLink\Support\ClaimFailure is a pure enum with eight cases:

CaseMeaning
NotFoundNo token matches the presented secret
ExpiredThe token exists but its lifetime has passed
AlreadyConsumedThe token was spent (or its remaining uses ran out)
InvalidCodeCode mode: the submitted code did not match
InvalidPassphraseThe link carries a passphrase gate and the submitted passphrase was wrong or missing
LockedOutThe per-token attempt cap was reached and the token was burned
RevokedInvitations only: the link was withdrawn before anybody used it — through revoke(), or because a newer invitation for the same address superseded it. The one refusal worth alerting on, which is why it is not folded into AlreadyConsumed
IneligibleThe token was genuine, but your SignInEligibility refused the account. The token is consumed and the sign-in refused, with the same response as any other failure. Worth alerting on: it is a refused account holding a valid link

MagicLinkRequestRefused​

EmailMagicLink\Events\MagicLinkRequestRefused(
RequestRefusal $reason,
string $email,
?ResendDecision $decision,
?Request $request,
)

The request endpoint refused to issue anything: the captcha failed (RequestRefusal::Captcha), the resend guard held the address back (ResendCooldown, ResendWindowCap, with the ResendDecision that says how long), or a concurrent request for the same address was still issuing and outlasted the wait budget (IssuanceContended, with a null decision — no resend rule was involved).

That last one is the only reason here that does not mean nothing was sent. The request holding the lock is the one sending the credential, so the person gets their link either way; this event exists because the response deliberately cannot say so. Sustained IssuanceContended on one address is either somebody hammering the button or somebody hammering it for them, and it is worth alerting on for that reason rather than as an error.

The response stays generic; the event is where a flood against the request form becomes visible, unknown addresses included. The framework's own throttle: limiter answers 429 before the controller runs and carries no event — listen to Illuminate\Foundation\Http\Events\RequestHandled with status 429 for that one.

TwoFactorChallengeRequired​

EmailMagicLink\Events\TwoFactorChallengeRequired(
Authenticatable $user,
?Request $request,
)

Fired by the Fortify bridge when a user whose two-factor authentication Fortify counts as enabled is handed to the challenge. See The two-factor handoff.

Listening​

use EmailMagicLink\Events\MagicLinkAuthenticated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;

Event::listen(function (MagicLinkAuthenticated $event): void {
Log::info('signed in via magic link', ['user' => $event->user->getAuthIdentifier(), 'guard' => $event->guard]);
});

Log the request's origin, never its URL. Every event carries the Request, and on the consume routes the request path is the plaintext token. After a single-use claim it is spent; after a wrong passphrase, and on a multi-use link, it is still live. Log $event->request->ip() and userAgent(), not fullUrl().

Invitation events​

EventWhenCarries
InvitationAcceptedafter the transaction that spent the invitation commitsthe AcceptedInvitation, whatever your handler returned (null if it signed nobody in), the request
InvitationRejectedan invitation link was refusedthe ClaimFailure the visitor never sees, the request

InvitationRejected is its own type rather than a MagicLinkConsumptionFailed, and the distinction is operational: a link-following scanner touching an invitation URL is routine, and folding it into the sign-in failure event would fire whatever alerting sits on that one.

InvitationAccepted fires after the commit, so a listener can rely on the acceptance having happened — and so a slow listener cannot hold a database transaction open.