Configuration reference
All values live in config/email-magic-link.php. The narrative walkthrough — which decision
each key belongs to — is in Configuration.
Channel
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
enabled | true | EMAIL_MAGIC_LINK_ENABLED | Master switch for the channel (routes, notifications, limiters). |
mode | 'link' | EMAIL_MAGIC_LINK_MODE | 'link', 'code', or 'both'. |
Token lifetime and redemption
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
ttl | 900 | EMAIL_MAGIC_LINK_TTL | Default token lifetime in seconds. A value that is not a number, or is below 1, keeps 900, and a fraction above 1 counts by its whole part. |
link_ttl | null | EMAIL_MAGIC_LINK_LINK_TTL | Link lifetime in seconds; inherits ttl when unset or non-positive. |
code_ttl | null | EMAIL_MAGIC_LINK_CODE_TTL | Code lifetime in seconds; inherits ttl when unset (handy for a shorter, hand-typed code). |
max_uses | 1 | — | Default redemptions per link (1 = single-use). Override per link via issueLink($user, maxUses: N). |
One-time codes
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
code_length | 8 | — | One-time code length. |
code_alphabet | 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' | — | Alphabet for codes (governs keyspace); omits visually ambiguous characters. |
max_attempts_per_token | 5 | EMAIL_MAGIC_LINK_MAX_ATTEMPTS_PER_TOKEN | Hard per-token lockout for code mode. A value that is not a number, or is below 1, keeps 5, and a fraction above 1 counts by its whole part. |
entropy_safety_factor | 1_000_000 | — | Guardrail bar; cannot be lowered below this floor. |
Guards and resolution
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
guard | application default | EMAIL_MAGIC_LINK_GUARD | Default stateful guard to log into. |
guards | [] | — | Extra guards a request may select via a guard field. |
user_lookup | null (bundled) | — | UserLookup implementation. |
eligibility | null (everyone signs in) | — | SignInEligibility implementation, asked when a credential is issued and again when it is redeemed. |
token_store | null (bundled) | — | TokenStore implementation. |
captcha | null | — | CaptchaGuard implementation, run before any user lookup. |
notification | MagicLinkNotification::class | — | Notification class (extend it to customize). |
mail_identity | null (app.name, bundled greeting) | — | MailIdentity implementation, asked with the account in the issuing request for the application's name and the greeting of the sign-in mail. |
Routing
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
routes.prefix | '' | — | Route prefix. |
routes.middleware | ['web'] | — | Route middleware (sessions + CSRF). |
routes.redirect_to | '/' | — | Fallback redirect after a login this package completes; a two-factor user lands where Fortify redirects after its challenge. |
routes.intended | true | — | Return to the originally requested URL after login. |
api.enabled | false | — | Direct JSON token exchange for SPA and mobile clients. |
Staying signed in
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
remember.enabled | false | EMAIL_MAGIC_LINK_REMEMBER | Offer "Stay signed in" on the confirmation screen and the code form. A ticked box sets Laravel's remember cookie, which lasts 400 days unless the guard sets remember in config/auth.php; while this is off, a posted remember field has no effect. Each guard's user table needs Laravel's remember_token column, or a ticked sign-in fails after the link or code is spent. Accepting an invitation never sets the cookie. |
Invalid or expired links
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
invalid_response.via | 'redirect' | — | Browser response for an invalid or expired link, a refused code and a dead invitation: 'redirect', 'view', 'abort', 'json', or a custom InvalidLinkResponder class-string. |
invalid_response.view | 'email-magic-link::invalid' | — | View the 'view' strategy renders (receives a message), answered with abort_status. |
invalid_response.redirect_to | null | — | Redirect target for the 'redirect' strategy; null sends the request back to the form it came from, the sign-in form or the code form. |
invalid_response.abort_status | 403 | — | HTTP status the 'abort' and 'view' strategies answer with. |
invalid_response.error_code | 'invalid_or_expired' | — | Stable JSON error code (JSON clients and the 'json' strategy). |
User interface
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
ui.mode | 'auto' | EMAIL_MAGIC_LINK_UI | 'auto' (WireKit views if installed) or 'blade'. |
ui.vite | ['resources/css/app.css'] | — | Vite entries the WireKit layout loads; false or [] for a non-Vite host. |
ui.styles | [] | — | Plain stylesheet URLs to <link> into the WireKit layout. |
ui.script_nonce | null | — | Class implementing ScriptNonce, supplying the CSP nonce for every tag the bundled screens emit (the countdown script, the inline stylesheet, WireKit's tags). null reads the csp-nonce container binding spatie/laravel-csp registers, then a global csp_nonce(). |
ui.header_view | null | — | View rendered above the card on every screen this package draws, in both view trees — a wordmark, a language switcher. A name that resolves to no view is dropped and logged at warning, never thrown. |
ui.footer_view | null | — | The same slot below the card. Same fallback: an unresolvable name spoils nothing. |
ui.layout | null | — | A Blade component of yours that the WireKit screens render inside, named as the tag without x- ('layouts.auth'). It receives the page's title as title and the card as its slot; its <head>, frame and theme are yours, and header_view, footer_view, html_class, vite and styles are not read while it is set. A name that resolves to no component is dropped and logged at warning, and the screens keep their own layout. |
ui.heading_size | null | — | Size of the main heading on the WireKit screens, one of WireKit's heading sizes (sm to 5xl). The level stays 1. null leaves WireKit's size for a level-one heading; an unknown size is logged and drawn at WireKit's fallback. The plain Blade screens do not read it. |
ui.html_class | null | — | Class attribute of the WireKit layout's <html> element. WireKit switches its tokens to the dark scheme under .dark, so 'dark' renders the screens dark. null renders no attribute. The plain Blade screens do not read it. |
ui.sign_in_url | null | — | Your application's own sign-in page, as a URL or a path. After a link or a code was turned away, the screens of both view trees link to it beside the way to a new one. null links nothing. See After a link or a code was turned away. |
Fortify bridge
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
fortify.mode | 'auto' | EMAIL_MAGIC_LINK_FORTIFY | 'auto' (on if Fortify present), true, or false. An empty value counts as not set and stays 'auto'. |
fortify.respect_two_factor | true | — | Route users with two-factor enabled through the challenge. |
fortify.challenge_route | 'two-factor.login' | — | Fortify challenge route name. |
Rate limiting
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
limiters.request | 'email-magic-link:request' | — | Named limiter on the request endpoint; override with RateLimiter::for(). |
limiters.consume | 'email-magic-link:consume' | — | Named limiter on the three endpoints that spend a credential. |
limiters.invitation_view | 'email-magic-link:invitation-view' | — | Named limiter on the invitation display page, which spends nothing — its own budget, so viewing an invitation cannot use up the allowance accepting one needs. |
limits.request | ['max' => 5, 'per_minutes' => 1] | EMAIL_MAGIC_LINK_REQUEST_MAX, EMAIL_MAGIC_LINK_REQUEST_PER_MINUTES | Defaults the bundled request limiter uses. |
limits.consume | ['max' => 10, 'per_minutes' => 1] | EMAIL_MAGIC_LINK_CONSUME_MAX, EMAIL_MAGIC_LINK_CONSUME_PER_MINUTES | Defaults the bundled consume limiter uses. |
limits.invitation_view | ['max' => 30, 'per_minutes' => 1] | EMAIL_MAGIC_LINK_INVITATION_VIEW_MAX, EMAIL_MAGIC_LINK_INVITATION_VIEW_PER_MINUTES | Defaults the bundled invitation-display limiter uses. Higher than consume: it guards a page load, not a credential being spent. For each of the three limits, a value that is not a number, or is below 1, keeps the default, and a fraction above 1 counts by its whole part. |
Token pruning
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
prune.schedule | false | — | Register the purge in your scheduler, at prune.frequency. Off by default — see the purge command. |
prune.frequency | 'daily' | — | hourly, daily, weekly or monthly. An unrecognized value falls back to daily. |
prune.chunk | 1000 | — | Rows one DELETE removes; the purge loops until nothing is left, so this bounds how long a single statement holds its row locks. A value above 30,000 is held to 30,000: the delete binds every id of its chunk, and a database refuses a statement past its parameter ceiling (32,766 on SQLite, 65,535 on PostgreSQL and MySQL). |
Resend guard
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
resend.enabled | true | EMAIL_MAGIC_LINK_RESEND | Throttling on this package's request endpoint. Keys your own application guards are unaffected. An empty value counts as not set and keeps it on. |
resend.cooldown.base | 30 | — | First cooldown in seconds after a send. |
resend.cooldown.factor | 2 | — | Multiplier applied to each subsequent cooldown. |
resend.cooldown.max | 900 | — | Ceiling for the escalating cooldown, in seconds. |
resend.window.minutes | 60 | EMAIL_MAGIC_LINK_RESEND_WINDOW_MINUTES | Length of the rolling window. A value that is not a number, or is below 1, keeps 60, and a fraction above 1 counts by its whole part. |
resend.window.max_sends | 5 | EMAIL_MAGIC_LINK_RESEND_MAX_SENDS | Maximum sends within that window. A value that is not a number, or is below 1, keeps 5, and a fraction above 1 counts by its whole part. |
lock_store | null | EMAIL_MAGIC_LINK_LOCK_STORE | Cache store the issuance lock uses; null uses the default. Its locks have to really exclude — the null driver implements the lock contract and hands out a lock that always succeeds, so the package refuses it rather than pretending to serialize. Sibling of resend.store: pointing only that one at a lockable store used to fix the resend guard and leave issuance throwing. |
lock_block_seconds | 5 | EMAIL_MAGIC_LINK_LOCK_BLOCK_SECONDS | How long a second programmatic issuance for the same address waits for the first to finish. It does not apply to the sign-in request endpoint, which never waits: the lock is taken only for an address that resolves to a user, so queueing for it would be a readable answer to whether that account exists. A latency ceiling for the callers it does govern — a waiter holds a worker for this long. A value that is not a number, or is below 1, keeps 5, and a fraction above 1 counts by its whole part. |
lock_hold_seconds | 60 | EMAIL_MAGIC_LINK_LOCK_HOLD_SECONDS | The lock's TTL — how long it survives a process that died holding it. Not the same budget as the wait: a TTL shorter than the issuance it protects expires mid-work and lets a second request in, which is the one failure the lock exists to prevent and the one that leaves no trace. A value that is not a number, or is below 1, keeps 60, and a fraction above 1 counts by its whole part. |
resend.store | null | — | Cache store for the guard; null uses the default. Must support atomic locks. The guard's state is a cache entry: cache:clear resets every cooldown, and the array store holds it for one process only. |
Invitations
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
invitations.enabled | false | EMAIL_MAGIC_LINK_INVITATIONS_ENABLED | Register the invitation channel. Requires handler and view; the package refuses to boot without them. |
invitations.ttl | 604800 | EMAIL_MAGIC_LINK_INVITATION_TTL | Invitation lifetime in seconds (7 days). Floored at 60. A value that is not a number, or is below 1, keeps 604800, and a fraction above 1 counts by its whole part. |
invitations.store | null | — | Class implementing InvitationStore; null uses the bundled Eloquent store. |
invitations.handler | null | — | Class implementing InvitationHandler — what accepting an invitation means in your application. Required when enabled. |
invitations.view | null | — | Your acceptance screen. The package ships none. Required when enabled. |
invitations.redirect_to | '/' | — | Where to send someone whose acceptance produced no session. |
invitations.retain_accepted_days | 30 | EMAIL_MAGIC_LINK_INVITATION_RETAIN_ACCEPTED_DAYS | How long accepted and revoked rows survive the purge. They carry the invited address in the clear, so this is a retention decision; 0 deletes them as soon as they settle. A value that is not a number, or is negative, keeps 30, and a fraction counts by its whole part. |
Replacing the models
| Key | Default | Environment variable | Purpose |
|---|---|---|---|
models | [] | — | Your subclasses of MagicLinkToken and Invitation, keyed by the package class. The package then uses your class on every path: every query, every row it writes, and the connection a claim runs on. A class that does not exist, or does not extend the package class, is ignored in favor of the package class. |
'models' => [
\EmailMagicLink\Models\MagicLinkToken::class => \App\Models\MagicLinkToken::class,
],
Use it when you need your own relations, scopes or casts on a token or an invitation row. Your subclass inherits the package's table name, so it reads and writes the same rows — keep it that way, since a subclass with its own $table points the package at a table its migrations never created. In your own code, MagicLinkToken::model() returns the configured class and MagicLinkToken::resolve() a new instance of it.
This is a lighter tool than the store contracts. Bind your own TokenStore or InvitationStore when you want to change how tokens are stored; map a subclass here when the storage is fine and you only need more on the row.