Configuration
All values live in config/email-magic-link.php, published by the installer or by hand:
php artisan vendor:publish --tag=email-magic-link-config
Every key, its default and its environment variable are in the configuration reference. This page explains the decisions behind them.
Your published file is a starting point, not a ceiling. Keys you leave out — at any depth —
take the shipped default, so a file published against an older version keeps working when a
new nested key arrives. A list you set (guards, routes.middleware, ui.vite,
ui.styles) replaces the shipped list entirely, including an empty one.
The master switch
'enabled' => env('EMAIL_MAGIC_LINK_ENABLED', true),
With enabled = false the package becomes inert: no routes, no notifications, no rate
limiters. This is independent of whether Fortify is installed, and it is also what the
Mint API checks before minting — a disabled channel
throws MagicLinkDisabledException rather than handing out a credential that could never be
consumed.
Link, code, or both
'mode' => env('EMAIL_MAGIC_LINK_MODE', 'link'),
'link' emails a high-entropy magic link, 'code' emails a short one-time code, and
'both' offers either. In 'both' mode the request endpoint issues a link by default, or a
code when channel=code is submitted. Code mode is bound by a boot-time entropy guardrail —
see One-time codes.
All routes are registered whenever the channel is enabled; the mode governs which one actually issues a token, not which routes exist.
Token lifetimes
'ttl' => (int) env('EMAIL_MAGIC_LINK_TTL', 900),
'link_ttl' => env('EMAIL_MAGIC_LINK_LINK_TTL'),
'code_ttl' => env('EMAIL_MAGIC_LINK_CODE_TTL'),
ttl is the default lifetime in seconds for both channels. Set link_ttl or code_ttl to a
positive number of seconds to give a channel its own lifetime — a shorter code that is typed
by hand, for example. A null or non-positive override inherits ttl. Expired tokens are
rejected regardless of whether they were ever consumed.
The three configurations
Standalone — no Fortify. A verified user is logged in directly with Auth::login. There
is no second factor in standalone mode, by design.
With Fortify, bridge on (fortify.mode = 'auto', the default). A user with confirmed
TOTP is routed through Fortify's challenge; everyone else logs in directly.
With Fortify, bridge off (fortify.mode = false). Fortify can be installed for other
flows while the magic-link channel ignores it entirely and logs users in directly.
The channel itself can be turned off completely with enabled = false, independent of
whether Fortify is installed. The handoff itself — including the guard-alignment rule it
depends on — is described in The two-factor handoff.
Invalid or expired links
By default an invalid or expired link redirects the browser back to the sign-in form with a
generic message. Set invalid_response.via to change that — render your own view, abort()
with an HTTP status, or return the JSON envelope to every client — or point it at a class
implementing EmailMagicLink\Contracts\InvalidLinkResponder for full control:
'invalid_response' => [
'via' => 'view', // 'redirect' | 'view' | 'abort' | 'json' | YourResponder::class
'view' => 'email-magic-link::invalid',
'redirect_to' => null,
'abort_status' => 403,
'error_code' => 'invalid_or_expired',
],
| Strategy | What the browser gets | Status |
|---|---|---|
redirect | Back to the sign-in form (or redirect_to) with the generic error flashed and the email re-prefilled — the default | 302 |
view | The view key is rendered and receives a message variable | abort_status (403) |
abort | abort() with abort_status, using your application's error page | abort_status (403) |
json | The {message, error} envelope, returned to every client | 422 |
No strategy answers a dead link with 200: a refusal at a success status reads as a success to a link checker, an HTTP cache and a crawler, and the invitation page is a URL that carries a token.
Whichever strategy you pick, the response never reveals whether the token was unknown or
merely expired, so the flow stays enumeration-resistant. error_code is the stable machine
code the JSON envelope carries — see The JSON contract.
Routing
'routes' => [
'prefix' => '',
'middleware' => ['web'],
'redirect_to' => '/',
'intended' => true,
],
The browser flow needs the web middleware group for sessions and CSRF. redirect_to is the
fallback destination after a successful login when no intended URL was captured; with
intended = true (the default) the user returns to the protected route that triggered the
flow instead.
Rate limits
'limiters' => [
'request' => 'email-magic-link:request',
'consume' => 'email-magic-link:consume',
'invitation_view' => 'email-magic-link:invitation-view',
],
'limits' => [
'request' => ['max' => 5, 'per_minutes' => 1],
'consume' => ['max' => 10, 'per_minutes' => 1],
'invitation_view' => ['max' => 30, 'per_minutes' => 1],
],
Three named limiters. Redefine any of them from your application with RateLimiter::for()
using the same name to take full control; limits only feeds the bundled definitions.
request guards the one endpoint that issues a credential. consume guards the three that
spend one. invitation_view guards the single route that is throttled without spending
anything — the page that displays an invitation — and it has its own budget on purpose:
sharing the consume budget would mean looking at an invitation used up the allowance
accepting it needs, and behind one shared egress address (an office, a carrier's CGNAT, a
school) every user on it would share the cost. Its default is higher for the same reason: a
page load is cheap, and a person may open the link more than once.
These are fixed-window limits. The escalating cooldown that stops a repeatedly clicked "send again" from flooding an inbox is a separate layer — see The resend guard.
Guards and user resolution
'guard' => env('EMAIL_MAGIC_LINK_GUARD'),
'guards' => [],
'user_lookup' => null,
Leave guard null to use the application default. guards lists additional guards a request
may sign in to — see Multiple guards. Provide a user_lookup
class implementing the UserLookup contract to fully control how a submitted email resolves
to a user (custom columns, multi-tenancy, soft deletes).
The user interface
'ui' => [
'mode' => env('EMAIL_MAGIC_LINK_UI', 'auto'),
'vite' => ['resources/css/app.css'],
'styles' => [],
'script_nonce' => null,
],
'auto' renders WireKit views when pushery/wirekit is installed and
the plain Blade views otherwise; 'blade' always serves the plain views. vite and styles
only matter for the WireKit screens — see The WireKit screens.
script_nonce supplies the Content-Security-Policy nonce for every tag the bundled screens
emit that a strict policy would otherwise reject: the WireKit layout's inline stylesheet, the
<link> and <script> WireKit itself writes, and the resend countdown's script tag.
Leave it null and the package finds the nonce on its own. It reads the csp-nonce container
binding that spatie/laravel-csp registers — the same one that package's @cspNonce directive
reads — and falls back to a global csp_nonce() function for applications that define one.
Point script_nonce at a class implementing ScriptNonce only when your nonce lives somewhere
else entirely.
Under a strict policy without a nonce these are blocked silently: nothing throws, the screen simply renders unstyled, with nothing in your logs.
The countdown is the exception, and deliberately so. Its script is served as an ordinary
same-origin file from /magic-link/resend-countdown.js, so a plain script-src 'self'
accepts it with no nonce and no configuration at all — which matters for an application whose
policy issues no nonces, because such a host has nothing it could pass through. It still takes
the nonce when there is one: a policy built on 'strict-dynamic' ignores 'self', and there
the nonce is the only thing that grants the tag.
What the cache instance has to survive
The package puts four different things in your cache, and they do not all tolerate the same treatment. Worth knowing before you point them all at one Redis that was sized for caching:
| What | Key | Losing it means |
|---|---|---|
| The issuance lock | eml:lock:* | two credentials issued for one address |
| The resend counters | eml:resend:* | a cooldown and a rolling window forgotten |
| The rate-limiter buckets | eml:req:*, eml:con:*, eml:inv:* | a bucket reset |
| The queued mail | your queue connection | a sign-in link that never arrives |
Three of those four are not caches. They are state whose whole value is that it persists for a few seconds or a few minutes, and every one of them fails open when it disappears.
- Eviction takes locks first, not last. A Redis lock is written with a TTL (
SET … EX … NX), so under anyvolatile-*policy it is not merely eligible for eviction, it is preferred. Underallkeys-*it goes with everything else. Onlynoevictionis safe. If your cache instance is under memory pressure and set to evict, give the packagelock_storeandresend.storea separate connection. - A restart is a
cache:clearyou did not run. The documentation elsewhere warns thatphp artisan cache:clearresets every cooldown; a Redis restart without AOF does exactly the same, and on an instance dedicated to caching that is the normal configuration rather than an accident. - The lock and the cache can be different databases. Laravel's
lock_connectiondefaults todefault, which is often not the database your cache entries live in. That means themaxmemory-policyyou set for your cache is not necessarily the one deciding how long your locks survive.
Token pruning
'prune' => [
'schedule' => false,
'frequency' => 'daily',
],
Turn schedule on and the package registers email-magic-link:purge in your scheduler.
It is off by default so a package never deletes rows on a cadence you did not choose, and so
an application that already wires the command itself does not end up running it twice — see
Keeping the token table small.
Swappable collaborators
'user_lookup' => null,
'token_store' => null,
'captcha' => null,
'notification' => MagicLinkNotification::class,
Each of these takes a class of yours. What every contract guarantees is in the contract reference; how to wire them is in Extension points.