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)— the request endpoint issued a link or code for a known user. The Mint API does not fire it.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). A two-factor user's login completes inside Fortify, which fires Laravel'sLoginbut not this event, so an audit log listens forTwoFactorChallengeRequiredandLoginas well.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 user whose two-factor authentication Fortify counts as enabled 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
Ten config keys each take a class of yours:
| Key | Contract | What you control |
|---|---|---|
notification | — (extend MagicLinkNotification) | Branding, channels, and copy of the delivered mail |
mail_identity | MailIdentity | The application's name and the greeting in the sign-in mail, per account |
user_lookup | UserLookup | How a submitted email resolves to a user |
eligibility | SignInEligibility | Whether an account may be handed a session at all |
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.
Refuse a suspended account a session
Point the eligibility config at a class implementing
EmailMagicLink\Contracts\SignInEligibility:
final class NotSuspended implements SignInEligibility
{
public function allows(Authenticatable $user, string $guard): bool
{
return $user->suspended_at === null;
}
}
It is asked twice, and the second time is why this exists as a contract rather than as middleware. A middleware that logs a suspended account out runs on the request after the one that signed it in; by then a session was opened. Gating only the issuing endpoint is no better, because a link handed out a minute before the suspension is still a valid credential afterwards and nothing looks the address up when it comes back. So the package asks when the request endpoint issues and again on every redemption. The Mint API takes the user you resolved and leaves the first question to you.
On issue, a refused account resolves to nothing and the endpoint answers for it exactly as it
answers for an address nobody registered — same status, same redirect, same bytes. That is
deliberate: a distinguishable refusal would let somebody send one link and learn both that the
account exists and that it is suspended. On redemption the token is consumed and the sign-in
refused, and MagicLinkConsumptionFailed carries ClaimFailure::Ineligible.
Listen for that event rather than logging inside allows(). The issuing path calls the method
for accounts an attacker merely guessed at, so a log line there records somebody else's typing;
the event fires only where a genuine token was presented.
Decide from the account, never from the request — the request that asks for a link is not the one that redeems it, and a decision that depended on the first could not be repeated on the second.
Invitation acceptance is outside this gate. It runs through your own InvitationHandler, which is
the thing deciding what an account becomes.
Name the tenant and greet by name
Point the mail_identity config at a class implementing EmailMagicLink\Contracts\MailIdentity:
final class TenantMailIdentity implements MailIdentity
{
public function applicationName(Authenticatable $user): string
{
return $user->tenant->name;
}
public function greeting(Authenticatable $user): ?string
{
return $user->name === null ? null : __('Hello :name,', ['name' => $user->name]);
}
}
The name replaces app.name wherever the mail uses it, in the subject, the introduction and the
sign-off of the link mail and of the code mail. A null greeting keeps the bundled one. The mail
that goes out is still MagicLinkNotification, so tests that assert on it stay as they are.
It is asked in the request that issues the link, with the account, and the answers travel with the queued notification, so a tenant the request resolved is at hand and the greeting is in the request's language. Keep it to what the account and the request already hold: it runs only for addresses that belong to an account, and an exception from it is reported while the mail goes out under the bundled name and greeting.
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.