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.

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,
)

A link or code was issued 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.

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 confirmed-two-factor user 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, which makes it the precise signal for an audit log. Successful logins also fire Laravel's own Illuminate\Auth\Events\Login.

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 six 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

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 confirmed-two-factor user 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.