Extension points
Take over the post-verification flow
Rebind the authenticator contract:
use EmailMagicLink\Contracts\MagicLinkAuthenticator;
$this->app->bind(MagicLinkAuthenticator::class, MyAuthenticator::class);
The contract returns a response, so it — not an event — is where login-versus-two-factor is decided.
React to events
Events are observability only — they must not drive flow control:
MagicLinkRequested($user, $channel, $request)— a link or code was issued for a known user.MagicLinkVerified($user, $request)— a token was verified and consumed, before the authenticator runs.MagicLinkAuthenticated($user, $guard, $request)— the user was actually logged in (fires only on a completed login, never for a two-factor handoff), the precise signal for an audit log.MagicLinkConsumptionFailed($reason, $request)— a consume attempt failed;$reasonis aClaimFailure, so you can log every failure and alert specifically onLockedOut(a brute-force lockout) or repeatedInvalidCode.TwoFactorChallengeRequired($user, $request)(fired by the bridge) — a confirmed-two-factor user is being handed to the challenge.
Each carries the Request, so a listener can record the IP address and user agent. The
response stays generic and enumeration-resistant regardless of which failure reason fired.
Successful logins also fire Laravel's own Illuminate\Auth\Events\Login.
The full payloads and the ClaimFailure cases are in the
event reference.
Swap collaborators
Eight config keys each take a class of yours:
| Key | Contract | What you control |
|---|---|---|
notification | — (extend MagicLinkNotification) | Branding, channels, and copy of the delivered mail |
user_lookup | UserLookup | How a submitted email resolves to a user |
token_store | TokenStore | Persistence, hashing, and the atomic claim |
captcha | CaptchaGuard | A pre-issue challenge |
invalid_response.via | InvalidLinkResponder | The response to an invalid or expired link |
ui.script_nonce | ScriptNonce | The CSP nonce on every tag the bundled screens emit |
invitations.store | InvitationStore | Persistence, hashing, supersession and the atomic claim of invitations |
invitations.handler | InvitationHandler | What accepting an invitation means — required when invitations are on |
notification is the one entry that is not a contract: it takes a class extending
MagicLinkNotification, and a class that does not is ignored without raising.
What each contract guarantees is in the contract reference.
Gate requests with a CAPTCHA
Point the captcha config at a class implementing EmailMagicLink\Contracts\CaptchaGuard:
final class TurnstileGuard implements CaptchaGuard
{
public function passes(Request $request): bool
{
// Verify the challenge token (e.g. cf-turnstile-response) with the provider.
return Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => config('services.turnstile.secret'),
'response' => $request->input('cf-turnstile-response'),
])->json('success') === true;
}
}
It runs before any user lookup, so a failed challenge rejects the request identically whether
or not the email exists — it can never become an enumeration oracle. A failure returns the
captcha_failed JSON error (or a form error) and issues nothing.
Because it runs first, it is also the mitigation for the availability trade-off of the per-account send cap — see the hourly cap and availability.
Customize the invalid-link response
invalid_response.via accepts the class-string of your own
EmailMagicLink\Contracts\InvalidLinkResponder when none of the four built-in strategies
fits. See Invalid or expired links.
Customize the screens
Publish the Blade views and edit them:
php artisan vendor:publish --tag=email-magic-link-views
See The WireKit screens for how the WireKit variants are selected and styled.