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' => filter_var(env('EMAIL_MAGIC_LINK_ENABLED', true), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? 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' => EnvNumber::atLeastOne(env('EMAIL_MAGIC_LINK_TTL'), 900, 'EMAIL_MAGIC_LINK_TTL'),
'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. An EMAIL_MAGIC_LINK_TTL that is not a number, or is below 1, keeps 900, and a fraction above 1 counts by its whole part. 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 whose two-factor authentication Fortify counts as enabled is routed through Fortify's challenge; everyone else logs in directly. With Fortify's confirm option on, that means confirmed TOTP.

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' => 'redirect', // '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 form the request came from, the sign-in form or the code form (or to 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.

The strategy answers every refused credential, not only a link: a code the code form turns down and a dead invitation get the same response. A mistyped code has to look exactly like an expired or locked one, so it is answered the same way. With redirect and no redirect_to, the person lands back on the code form with the email kept and can type the code again. With view, abort or json, or with redirect_to set, the person leaves the code form after a typo. If you offer codes and want a page of your own for dead links, write a responder that sends a refused code back to the form: $failureRoute is email-magic-link.code.form then.

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.

With redirect, the bundled screens offer the ways forward beside the refusal. The code form links to the sign-in form for a new code, and both forms link to your application's own sign-in page when ui.sign_in_url names one:

'ui' => [
'sign_in_url' => '/login',
],

A screen of your own can tell a refusal from any other error without comparing the message. The redirect flashes EmailMagicLink\Support\MagicLinkRejection::SESSION_KEY beside the message, for an unknown, an expired and a used link or code alike, and MagicLinkRejection::flashed() reads it:

@if (\EmailMagicLink\Support\MagicLinkRejection::flashed())
<a href="{{ route('email-magic-link.request.form') }}">Request a new link</a>
@endif

The key says that a credential was refused and nothing about why, so the response stays the same for an address with an account and one without.

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. Both apply to a sign-in this package completes. A two-factor user's sign-in completes in Fortify's challenge, and Fortify redirects: to the intended URL, else fortify.redirects.login, else fortify.home.

Staying signed in​

'remember' => [
'enabled' => filter_var(env('EMAIL_MAGIC_LINK_REMEMBER', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false,
],

Off by default, so a sign-in lasts as long as the session: SESSION_LIFETIME minutes without activity, and then the person asks for a new link. Turned on, the confirmation screen and the code form offer a "Stay signed in" checkbox, unchecked until the person ticks it. A ticked box signs in with Laravel's remember cookie, which keeps the person signed in on that device past the session lifetime, the way "Remember me" does on a password form. With the two-factor handoff the choice travels to Fortify as login.remember, and Fortify sets the cookie once the second factor has passed.

How long a remembered sign-in lasts is Laravel's setting, not this package's. The remember cookie lives for 400 days unless the guard sets remember, in minutes, in config/auth.php:

'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
'remember' => 60 * 24 * 30, // 30 days
],
],

Laravel keeps a remembered sign-in by writing a token to the user's remember_token column, which the users migration of a new Laravel application already creates. The box appears for every guard the package signs in on, the default one and those in guards, so each of their user tables needs that column. A table without it, typically one you created yourself for a second guard, makes a ticked sign-in fail with a database error, after the link or code has already been spent. While the switch is on, php artisan email-magic-link:doctor names every such table.

The switch holds on the server, not only in the markup: while it is off, a remember field a client posts anyway changes nothing. Accepting an invitation never sets the cookie, whatever this switch says.

The bundled views draw the box when they are handed $offerRemember. A view you published before this setting existed draws none until you add it: a checkbox named remember with the value 1, inside the form.

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.

Each number can also come from the environment, so tightening one does not require publishing the config: EMAIL_MAGIC_LINK_REQUEST_MAX and EMAIL_MAGIC_LINK_REQUEST_PER_MINUTES, EMAIL_MAGIC_LINK_CONSUME_MAX and EMAIL_MAGIC_LINK_CONSUME_PER_MINUTES, EMAIL_MAGIC_LINK_INVITATION_VIEW_MAX and EMAIL_MAGIC_LINK_INVITATION_VIEW_PER_MINUTES. A value that is not a number, or is below 1, keeps the default, so a typo cannot switch a limit off. A fraction above 1 counts by its whole part. php artisan email-magic-link:doctor names every variable whose value was set aside this way.

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.

Each limiter counts on two axes: per email or per token, and per source. A source is an IPv4 address as it is, and an IPv6 address by its /64, because an ordinary IPv6 connection is a whole /64 that a host can send from at any address in it. An IPv4 address written as IPv6 (::ffff:192.0.2.1) counts as that IPv4 address.

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). Whatever it queries, the account has to answer to email: the package checks that attribute against the submitted address before it uses the account, so a model whose address lives in another column needs an accessor, see UserLookup.

The user interface​

'ui' => [
'mode' => env('EMAIL_MAGIC_LINK_UI', 'auto'),
'vite' => ['resources/css/app.css'],
'styles' => [],
'script_nonce' => null,
'header_view' => null,
'footer_view' => null,
'html_class' => null,
'heading_size' => 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.

Your brand on the screens this package draws​

header_view and footer_view name two views rendered above and below the card, on every screen this package draws, in both view trees. Null renders nothing, which is the default.

They exist for one screen in particular. The confirm screen is where somebody decides, out of an e-mail, whether to sign in here — and it is the only screen of the flow that this package draws rather than you. Without these it carries no wordmark and no language switcher, while every other screen of your application has both. A screen whose whole purpose is a trust decision is the worst one to strip of the marks that earn it.

'ui' => [
'header_view' => 'brand.wordmark',
'footer_view' => 'brand.locale-switcher',
],

They sit outside <main>, so neither one is constrained to the card's width, and neither is announced as the main content of a sign-in screen.

Why slots rather than a view override per screen, which invitations has: an override hands you the whole screen — the form, the CSRF field, the token handling — and every later change to any of that becomes yours to carry by hand. These give you the two places where you have something to say and leave the rest here.

A name that resolves to no view is dropped, not thrown, and the miss is logged at warning with the key. This renders on the sign-in path: a typo that threw would lock everyone out of your application rather than spoil one screen. If you configured a slot and see nothing, read the log — the message names the setting and the view it could not find.

Your own layout around the WireKit screens​

Where the slots are not enough, because the page should carry your whole frame, your <head> and your theme, layout names a Blade component of yours that the WireKit screens render inside. The component receives the page's title as title and the card as its slot:

'ui' => [
'layout' => 'layouts.auth', // resources/views/components/layouts/auth.blade.php
],
@props(['title'])
<x-layouts.app :title="$title">
{{ $slot }}
</x-layouts.app>

The <head> is then yours, so your layout loads WireKit's and Livewire's assets the way your other WireKit pages do, and it decides the title, including the name of the brand a page stands on in an application that serves several. header_view, footer_view, html_class, vite and styles belong to the package's own layout and are not read while layout is set; your frame takes their place. The plain Blade screens keep their own layout. A name that resolves to no component is dropped and logged at warning, like a slot's, and the screens keep their own layout.

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, the ui.styles links and Vite tags of that layout (Vite keeps a nonce it already has), 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.

A dark host​

WireKit switches its tokens to the dark scheme under a dark class on the <html> element. The two slots above render inside <body>, so they cannot set it, and html_class can:

'ui' => [
'html_class' => 'dark',
],

The value lands on the WireKit layout's <html> element as written, so any class your theme keys on works the same way. null, the default, renders no class attribute. The plain Blade screens do not read this key.

The size of the card's heading​

Each WireKit screen has one main heading, a level-one heading, and WireKit draws that at its 2xl size. If the headings of your own forms are smaller, heading_size sets the size while the level stays 1:

'ui' => [
'heading_size' => 'xl',
],

Any of WireKit's heading sizes works, sm to 5xl. WireKit checks the value when the heading renders: an unknown size is logged and drawn at its fallback size. null, the default, leaves the size to WireKit. The plain Blade screens do not read this key.

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:issue:*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.
  • With the database store on PostgreSQL, give the locks a connection of their own. The store takes a lock with an INSERT and, when the key is taken, an UPDATE on the same connection. Inside a transaction, PostgreSQL aborts the transaction at the failed INSERT: an invite() or issueLink() you call within DB::transaction() fails at once with SQLSTATE 25P02 while another issue for the same address holds the lock, instead of waiting for it, and your transaction cannot go on. On a connection of its own the lock waits and times out as it should. Add a second connection with the same settings and name it in the store's lock_connection; email-magic-link:doctor reports the shared one.
// config/database.php
'connections' => [
'pgsql' => [/* your connection */],
'pgsql_locks' => [/* the same settings as pgsql */],
],
DB_CACHE_LOCK_CONNECTION=pgsql_locks

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,
'mail_identity' => null,

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.

Read once, not per request​

The package reads its configuration from the repository in force, so a value your middleware sets for a request reaches it. Under Octane that holds for its services as well: every request runs on a copy of the application, and the package builds them on that copy. Two kinds of key are read only once, though, and a value set later does not reach the package.

  • At boot, on every server: the routes (routes.prefix, routes.middleware, and whether invitations.enabled registers the invitation routes), the scheduled purge (prune.schedule, prune.frequency), and whether the two-factor handoff is wired at all (fortify.mode). Boot comes before any middleware, so give these one value per deployment.
  • Once per queue worker: a worker runs every job in one application, so a service it builds for one job serves the next. That covers the issuance lock (lock_store, lock_block_seconds, lock_hold_seconds), the keys tokens are hashed with (app.key, app.previous_keys), and the classes named for the swappable collaborators (token_store, user_lookup, eligibility, captcha, ui.script_nonce, invitations.store, invitations.handler, and a class given as invalid_response.via). A job that sets one of these for itself gets the value of the first job that built the service. Under Octane the same happens to a service resolved before the first request, in a provider's boot() or through Octane's warm list.