Contract reference
Every extension seam is an interface under EmailMagicLink\Contracts. Nine of them are
selected by a config key; the other four are resolved from the container and can be rebound.
| Contract | Selected by | Purpose |
|---|---|---|
MagicLinkIssuer | container | Mint a link or code without sending anything |
TokenStore | token_store | Issue, hash, and atomically claim tokens |
UserLookup | user_lookup | Resolve a submitted email to a user |
SignInEligibility | eligibility | Decide whether an account may be handed a session right now |
CaptchaGuard | captcha | A pre-issue challenge |
MailIdentity | mail_identity | Name the application and greet the recipient in the sign-in mail |
MagicLinkAuthenticator | container | Decide what happens after a token is verified |
InvalidLinkResponder | invalid_response.via | Render the response to an invalid or expired link |
ResendGuard | container | Escalating cooldown and rolling cap on repeated sends |
ScriptNonce | ui.script_nonce | Supply the CSP nonce for every tag the bundled screens emit |
InvitationIssuer | container | Mint an invitation for an address that may have no account |
InvitationStore | invitations.store | Issue, hash, supersede and atomically claim invitations |
InvitationHandler | invitations.handler | Decide what accepting an invitation means — required when invitations are on |
MagicLinkIssuer
public function issueLink(
Authenticatable $user,
?string $guard = null,
?int $maxUses = null,
?string $passphrase = null,
?string $baseUrl = null,
): IssuedLink;
public function issueCode(Authenticatable $user, ?string $guard = null): IssuedCode;
public function revokeFor(Authenticatable $user, ?string $guard = null): int;
The public Mint API. EmailMagicLink\Facades\EmailMagicLink is a thin wrapper over it.
IssuedLink carries url, expiresAt (a Carbon\CarbonInterface) and expiresInMinutes;
IssuedCode carries code, expiresAt and expiresInMinutes. Both are final readonly.
The interface rather than a concrete class is deliberate: an application that calls
Date::use(CarbonImmutable::class) gets a CarbonImmutable out of Eloquent's datetime
cast, and CarbonImmutable does not extend Illuminate\Support\Carbon — the two branches
part at DateTime and DateTimeImmutable, so no class type can accept both.
MagicLinkToken::isExpired() takes ?CarbonInterface for the same reason.
Guarantees: the credential is hashed at rest and consumed through the same flow as an emailed
one; the user is re-resolved through the guard's own provider (UserNotInGuardException if it
does not match); an unlisted guard raises UnknownGuardException; and a disabled channel
raises MagicLinkDisabledException rather than minting something unconsumable.
revokeFor() spends every open link and code the user holds on the guard (the default guard
when none is given) and returns how many were open. Call it when an account's address changes:
a link sent to the old address would otherwise sign in until it expires. It is not limited to
the guards in guards and works while the channel is off, because it closes something and
opens nothing.
See Minting links and codes yourself.
TokenStore
public function issue(
Authenticatable $user,
string $guard,
string $channel, // 'link' or 'code'
?int $maxUses = null,
?string $passphrase = null,
): IssuedToken;
public function claimLink(string $token, ?string $passphrase = null): ClaimResult;
public function requiresPassphrase(string $token): bool;
public function claimCode(Authenticatable $user, string $code, string $guard): ClaimResult;
public function revokeFor(Authenticatable $user, string $guard): int;
public function purge(): int;
IssuedToken carries the plaintext secret (in memory only, for the lifetime of the issuing
request) and the persisted record. ClaimResult is either successful with a token, or
failed with a ClaimFailure.
Guarantees a replacement must keep: only a keyed hash is ever persisted; a claim is a single race-free conditional update, so two concurrent requests for the same token can never both succeed; the remaining-uses counter is checked and decremented in that same statement; and a passphrase is verified before the token is spent.
revokeFor() marks every open token of the user on that guard consumed, so a later claim of
one fails like the claim of a spent token, and returns how many there were. It leaves the tokens
of other users and other guards alone and does not count a spent or expired one.
purge() deletes expired or consumed rows and returns the count — see
the purge command.
UserLookup
public function findByEmail(string $email, string $guard): ?Authenticatable;
The supported email-to-user path. Bind your own for custom columns, multi-tenancy, or soft
deletes. Returning null must be indistinguishable from a match in the HTTP response — the
package handles that, so the implementation only has to answer honestly.
The shipped lookup lowercases the submitted address and asks the database for it, so the
database's collation decides what matches. On a case-sensitive collation (PostgreSQL, SQLite)
a user stored as [email protected] can sign in with a password and never receives a link,
and the response is the generic "sent" either way. Store addresses lowercase, or bind your own
UserLookup that queries lower(email) behind a functional index. The package does not fall
back to a case-insensitive scan itself: an unindexed lower(email) on a large table is a full
scan that anybody can trigger by posting an unknown address.
Whatever a lookup returns, the package checks the account's own address before it uses it.
An account resolves only when its email attribute, trimmed and lowercased, is exactly the
submitted address, trimmed and lowercased. MySQL's default collations ignore accents, so a
lookup asked for victim@exämple.com finds the account stored as [email protected]. The
two are different mailboxes, and the first sits on a domain anyone can register. The package
refuses that match and answers exactly as it does for an unknown address. The check wraps your own
lookup as well as the shipped one, and so does the sign-in eligibility gate: whether you name it in
user_lookup or bind it with bind() or singleton() in a provider of your own, both wrap
whatever the binding resolves. An object registered with instance() is used as it is. An account
without a readable email attribute cannot be checked and resolves to nothing.
So a lookup on another column needs the model to answer to email. A lookup that queries
email_address finds the account, and the check then refuses it, with the generic "sent"
response and no mail. An accessor on the model is enough:
// app/Models/User.php
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function email(): Attribute
{
return Attribute::get(fn (): ?string => $this->email_address);
}
The link or code itself always goes to the address the account states, never to the typed
string: its mail route when the model declares one (routeNotificationForMail(), as with any
Laravel notification), its email attribute otherwise.
SignInEligibility
public function allows(Authenticatable $user, string $guard): bool;
Whether this account may sign in to this guard at this moment. Suspended, dormant, deleted-in-grace, never-verified: what those words mean is your application's business, and the package owns only the question. The default allows every account that exists, so an install that configures nothing behaves exactly as it did before the contract existed.
It is consulted at both ends of the flow, and the second one is the point. Gating issuance alone leaves a window as wide as a link's lifetime: a link handed out a minute before an account was suspended is still a valid credential afterwards, and nothing looks the address up when it comes back.
| what happens | |
|---|---|
| when a link or code is issued | a refused account resolves to nothing, so the endpoint answers for it exactly as it answers for an address nobody registered — same status, same redirect, same bytes |
| when one is redeemed | the token is consumed and the sign-in refused, reported as ClaimFailure::Ineligible through MagicLinkConsumptionFailed |
Returning false is a refusal, never an error. Do not log from inside the method: the issuing
path deliberately calls it for accounts that an attacker merely guessed at, so a log line there is
a record of somebody else's typing. Listen for MagicLinkConsumptionFailed with
ClaimFailure::Ineligible instead — the HTTP response stays generic on purpose, which makes the
event the only place you can tell a refused account holding a valid link from ordinary noise.
Decide from the account, never from the request. The issuing endpoint answers identically for an unknown address and a refused one, and a decision that depended on the request could not keep that promise on the redemption path, where there is no such request.
Invitation acceptance sits outside this gate. It runs through your own InvitationHandler,
which is the thing deciding what an account becomes; asking whether it may sign in during the
request that puts it into service would be asking you to contradict yourself.
CaptchaGuard
public function passes(Request $request): bool;
Runs before any user lookup, so a failed challenge rejects the request identically whether
or not the email exists. A failure returns the captcha_failed error and issues nothing. See
Gate requests with a CAPTCHA.
MailIdentity
public function applicationName(Authenticatable $user): string;
public function greeting(Authenticatable $user): ?string;
The name the sign-in mail gives the application, in its subject, its introduction and its sign-off,
and the line it opens with. Both are asked with the account the mail goes to. The default names the
application after app.name, or the host of app.url where that is blank, and returns null from
greeting(), which keeps the bundled greeting.
Use it where one codebase serves several brands or tenants and the mail should name the one the
recipient belongs to, or to greet the recipient by name. The mail that goes out stays
MagicLinkNotification, so a test that asserts on MagicLinkNotification::class keeps passing,
and nothing of the link and code wording has to be rebuilt in a subclass.
It is asked in the request that issues the credential, not where the mail is built. The notification is queued and rendered in a worker, where neither the account nor the request is at hand, so the answers travel with it. What the request knows, a tenant resolved from the host name for one, is therefore available to the class, and the request's locale is the one the mail is rendered in: return the greeting in it.
The request asks only for an address that resolves to an account, so the time an answer takes is time only a known address spends. Read what the account and the request already hold and leave remote calls out. An exception from either method is reported, and the mail goes out under the bundled name and greeting, because a request that failed for known addresses alone would say which addresses are known.
MagicLinkAuthenticator
public function authenticate(
Request $request,
Authenticatable $user,
string $guard,
bool $remember,
): Response;
Where login-versus-two-factor is decided. It returns a response, which is why it — not an event — is the seam for taking over the post-verification flow.
InvalidLinkResponder
public function respond(Request $request, string $message, string $failureRoute): Response;
Point invalid_response.via at your class-string when none of the four built-in strategies
fits. The $message handed in is already generic; keep it that way, or the response starts
distinguishing an unknown token from an expired one. See
Invalid or expired links.
It answers a refused code and a dead invitation as well as a dead link. $failureRoute names
the form to fall back to: email-magic-link.code.form for a code, email-magic-link.request.form
for a link or an invitation.
ResendGuard
public function attempt(string $key): ResendDecision;
public function peek(string $key): ResendDecision;
public function reset(string $key): void;
ResendDecision carries allowed, retryAfterSeconds, and a reason — a
ResendDenialReason of Cooldown or WindowCap — when it denied.
Guarantees: attempt() records a send only when it allows one, so polling cannot push a
cooldown out; peek() records nothing; and the implementation takes an atomic cache lock per
attempt.
It refuses to run on a store that cannot lock, rather than throttling nothing. That is
worth stating precisely, because this sentence used to say "failing closed on a lockless
store" and that was backwards for the store anybody means by lockless: null implements the
lock contract and hands out a lock that succeeds every time, so an interface check passed it
and the guard failed open — unlimited mail per address, with nothing in any log. Both
that store and apc, which is not a lock provider at all, are now rejected by name. See
The resend guard.
ScriptNonce
public function value(): ?string;
Returns the Content-Security-Policy nonce for the current response, or null when the
application does not use one.
Guarantees: null means the attribute is omitted entirely — nonce="" is not the same
thing and would fail a strict policy anyway.
The bundled AutoScriptNonce probes two sources, in order, so an application using
spatie/laravel-csp needs no configuration:
- the
csp-noncecontainer binding, which is what that package registers and what its own@cspNoncedirective reads. It is scoped per request, so the value here is the one the policy header carries. - a global
csp_nonce()function, for applications that define one themselves.
Both are probed by name, never by referencing the other package's symbols, so this package
stays installable without it. Either probe returns null rather than throwing when the source
exists but cannot answer for this response. That matters: the countdown is progressive
enhancement, so a sign-in screen must not fail over it.
Point ui.script_nonce at your own implementation when the nonce lives somewhere else — a
request attribute, a middleware-set binding, your own helper. See
The resend guard.
InvitationIssuer
public function invite(
string $email,
?string $guard = null,
?array $context = null,
?string $invitedBy = null,
?string $baseUrl = null,
?string $scope = null,
): IssuedInvitation;
public function revoke(string $email, ?string $guard = null, ?string $scope = null): int;
Issues invitations for an address without sending mail — the counterpart to MagicLinkIssuer,
issued for an address that may have no account behind it. invite() supersedes any earlier
unaccepted invitation for the same address, guard and scope, an unscoped one the unscoped ones
only; context is stored verbatim and handed back to your handler, never interpreted; baseUrl
builds the link for another host, and the signature binds to it; scope names who the invitation
belongs to beyond its guard, such as a tenant. revoke() withdraws every unaccepted invitation for
the address on one guard, the default unless you pass another, in the scope you name or in every
scope, and returns how many; an accepted one is left alone.
IssuedInvitation carries url, email, guard, expiresAt, expiresInMinutes and scope.
InvitationStore
public function issue(string $email, string $guard, ?array $context = null, ?string $invitedBy = null, ?int $ttl = null, ?string $scope = null): IssuedInvitationToken;
public function peek(string $token): InvitationClaimResult;
public function claim(string $token): InvitationClaimResult;
public function revoke(string $email, string $guard, ?string $scope = null): int;
public function purge(): int;
Selected by invitations.store. Only a keyed hash of the token is stored; peek() looks an
invitation up without spending it (what lets the GET refuse a dead one before rendering your
view); claim() is a single conditional update, so two concurrent acceptances cannot both
succeed; purge() deletes expired invitations and settled ones past
invitations.retain_accepted_days.
InvitationHandler
public function accept(AcceptedInvitation $invitation, Request $request): ?Authenticatable;
Selected by invitations.handler, required when invitations are on. What accepting means in
your application: create the account, set the password, grant the roles. Return the user and
the package signs them in through the same path a magic link uses, two-factor handoff
included; return null to accept without a session. Runs inside the transaction that spends
the token, so an exception rolls the acceptance back and the link keeps working. See
Invitations.