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:
| 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 |
Ineligible | The 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
| 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.