Skip to main content

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.

ContractSelected byPurpose
MagicLinkIssuercontainerMint a link or code without sending anything
TokenStoretoken_storeIssue, hash, and atomically claim tokens
UserLookupuser_lookupResolve a submitted email to a user
CaptchaGuardcaptchaA pre-issue challenge
MagicLinkAuthenticatorcontainerDecide what happens after a token is verified
InvalidLinkResponderinvalid_response.viaRender the response to an invalid or expired link
ResendGuardcontainerEscalating cooldown and rolling cap on repeated sends
ScriptNonceui.script_nonceSupply the CSP nonce for every tag the bundled screens emit
InvitationIssuercontainerMint an invitation for an address that may have no account
InvitationStoreinvitations.storeIssue, hash, supersede and atomically claim invitations
InvitationHandlerinvitations.handlerDecide 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 entirelynonce="" 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:

  1. the csp-nonce container binding, which is what that package registers and what its own @cspNonce directive reads. It is scoped per request, so the value here is the one the policy header carries.
  2. 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.