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:
| Case | Meaning |
|---|---|
NotFound | No token matches the presented secret |
Expired | The token exists but its lifetime has passed |
AlreadyConsumed | The token was spent (or its remaining uses ran out) |
InvalidCode | Code mode: the submitted code did not match |
InvalidPassphrase | The link carries a passphrase gate and the submitted passphrase was wrong or missing |
LockedOut | The per-token attempt cap was reached and the token was burned |
Revoked | Invitations 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
| Event | When | Carries |
|---|---|---|
InvitationAccepted | after the transaction that spent the invitation commits | the AcceptedInvitation, whatever your handler returned (null if it signed nobody in), the request |
InvitationRejected | an invitation link was refused | the 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.