Skip to main content

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.

'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.

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',
],
StrategyWhat the browser getsStatus
redirectBack to the sign-in form (or redirect_to) with the generic error flashed and the email re-prefilled — the default302
viewThe view key is rendered and receives a message variableabort_status (403)
abortabort() with abort_status, using your application's error pageabort_status (403)
jsonThe {message, error} envelope, returned to every client422

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:

WhatKeyLosing it means
The issuance lockeml:lock:*two credentials issued for one address
The resend counterseml:resend:*a cooldown and a rolling window forgotten
The rate-limiter bucketseml:req:*, eml:con:*, eml:inv:*a bucket reset
The queued mailyour queue connectiona 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 any volatile-* policy it is not merely eligible for eviction, it is preferred. Under allkeys-* it goes with everything else. Only noeviction is safe. If your cache instance is under memory pressure and set to evict, give the package lock_store and resend.store a separate connection.
  • A restart is a cache:clear you did not run. The documentation elsewhere warns that php artisan cache:clear resets 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_connection defaults to default, which is often not the database your cache entries live in. That means the maxmemory-policy you 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.