Contract reference
Every extension seam is an interface under EmailMagicLink\Contracts. Seven 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 |
CaptchaGuard | captcha | A pre-issue challenge |
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;
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.
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 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.
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 then compares it exactly. 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.
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.
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.
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,
): IssuedInvitation;
public function revoke(string $email, ?string $guard = 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 and guard; 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. revoke() withdraws every unaccepted invitation for the address and
returns how many; an accepted one is left alone.
IssuedInvitation carries url, expiresAt and expiresInMinutes.
InvitationStore
public function issue(string $email, string $guard, ?array $context = null, ?string $invitedBy = null, ?int $ttl = null): IssuedInvitationToken;
public function peek(string $token): InvitationClaimResult;
public function claim(string $token): InvitationClaimResult;
public function revoke(string $email, string $guard): 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.