arzcode / filament-magic-login
Passwordless "email me a login link" option for Filament panels, alongside the standard password form.
Requires
- php: ^8.3
- filament/filament: ^5.0
- illuminate/contracts: ^12.0 || ^13.0
- laravel/prompts: ^0.3
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0 || ^5.0
- pestphp/pest-plugin-livewire: ^4.0 || ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
Passwordless "email me a login link" for Filament panels, added alongside the standard email/password form rather than replacing it. Multi-panel aware, single-use hashed tokens, translated into English, Spanish and Catalan.
The action appears in one of two places, chosen per panel:
Nothing about the password form changes: password login, password reset and registration keep working exactly as before.
Requirements
| PHP | 8.3+ |
| Laravel | 12 or 13 |
| Filament | 5 |
Installation
composer require arzcode/filament-magic-login php artisan filament-magic-login:install
The install command offers to publish the config file and — unless you have chosen the cache
storage driver — to publish and run the migration that creates the magic_login_tokens table and
to schedule the token pruner in routes/console.php. It then offers to register the plugin on a
panel for you, which is the step that puts the action on your login page, and finally to add the
"send a login link" action to your user resource.
Every step is a question and every one can be declined; nothing here is mandatory. Each one defaults to yes — pressing Enter through the command installs the package — except publishing the config file, which defaults to no: every option has a working default in the package, so that file is only worth having when you mean to edit it, and the defaults still apply without it.
Because the answers are the defaults, running it with --no-interaction (CI, a Docker build) wires
everything up unattended. Every edit it makes is named as it happens, only ever adds to your code,
and filament-magic-login:uninstall takes it all back out again; anything it cannot rewrite with
certainty is printed for you to paste rather than guessed at. It also stays quiet about whatever is
already in place, so re-running the command is safe.
Your users table is never touched.
Uninstalling
php artisan filament-magic-login:uninstall composer remove arzcode/filament-magic-login
The uninstall command undoes what the install command did, in the order that leaves the application working at every step:
- Removes
->plugin(MagicLoginPlugin::make())from your panel providers, theHasMagicLinkActiontrait from any custom login page,SendMagicLinkAction::make()from whichever action arrays hold it, and the scheduled pruner fromroutes/console.php, along with the now-unused imports — Laravel's ownScheduleimport included, where nothing else came to use it. Without this,composer removewould leave your source pointing at classes that no longer exist and every request would fail. An array left with nothing in it is kept rather than deleted — agetHeaderActions()still has to return one. - Drops the
magic_login_tokenstable. - Deletes the published config, migration and translations.
Both destructive steps are confirmed first, and the table question defaults to no. Keeping the table is a fine answer — installing the package again finds it in place and the migration it republishes steps over it rather than failing — but it does mean the old tokens are still there, so the command says as much and names the flag that drops it.
| Option | Effect |
|---|---|
--force |
Skip every confirmation (for CI or scripted teardown). |
--drop-tokens |
Drop the table without asking, keeping the other confirmations. |
--keep-tokens |
Leave the table and its rows alone. |
--keep-code |
Do not touch your source; report the panels to unwire by hand instead. |
The code edits work on PHP's token stream, not regular expressions, and the result is parsed
before it is written — a file that would not compile is left untouched. Registrations it cannot
rewrite with certainty (a grouped use A, B;, a plugin built through a variable, a conditional
registration) are reported with their file and line numbers rather than guessed at, as are any
other references such as event listeners importing MagicLinkConsumed.
Register the plugin
filament-magic-login:install offers to do this for you. To do it by hand, add the plugin to any
panel that already calls ->login():
use Arzcode\FilamentMagicLogin\MagicLoginPlugin; public function panel(Panel $panel): Panel { return $panel ->id('admin') ->login() ->plugin(MagicLoginPlugin::make()); }
That is the whole setup. The plugin swaps Filament's stock login page for its own subclass, so you do not have to create any files. The installer only edits a panel provider it can rewrite with certainty — a provider that returns its panel from more than one place, for instance, is reported and left for you. If your panel uses a custom login page, see Using your own login page.
Sending a link to a user
An administrator can email a specific user a login link from inside a panel, choosing how long it lives — for somebody being onboarded who has never set a password, for a user whose reset mail keeps bouncing, for a contractor who needs a way in until Monday.
use Arzcode\FilamentMagicLogin\Actions\SendMagicLinkAction;
One class covers all three placements. In the resource's table:
->recordActions([ EditAction::make(), SendMagicLinkAction::make(), ])
and in getHeaderActions() on the View and Edit pages:
protected function getHeaderActions(): array { return [ SendMagicLinkAction::make(), ]; }
In the table row the action renders as an icon button — hover for its label — while the two
page-header actions keep their label. Pass any explicit style (->button(), ->iconButton(),
->link()) to override that per placement.
filament-magic-login:install offers to do all of that for you. It finds your user resource by
comparing each resource's $model against the model your panel's guard authenticates, and follows
a resource to an extracted table class through its own imports. If it cannot identify one resource
with certainty — none match, or two do — it prints the snippet and leaves your source alone.
Re-generating the resource with make:filament-resource --force will wipe the wiring; re-run the
installer, or paste it back.
Three things worth being explicit about:
- The link is emailed to that user, and never shown to the administrator who sent it. There is no "copy link" option, deliberately: this is a way to help somebody log in, not a way to log in as them.
- The administrator is told exactly what happened — sent, no email address, cannot access this panel, or too many links sent — unlike the login page, which stays deliberately silent. That is safe here because the person reading it is authenticated and can already see the users table, so none of those answers tells them anything new.
- The expiry is chosen per send from a row of toggle buttons, with a "custom" choice for
anything else, and is clamped to
admin.max_expires_after_minutes(three days by default). The clamped value is what gets stored, what the email says, and what the confirmation quotes, so the three can never disagree.
| Fluent method | Default | What it does |
|---|---|---|
panel(string|Closure|null) |
the current panel | Which panel the link is minted for. |
panels(array|Closure|null) |
— | Panels to consider, best first: the first one the user can reach wins. |
anyPanel(bool|Closure) |
false |
The same, over every panel that registers the plugin. |
expiresAfter(int|Closure|null) |
admin.expires_after_minutes |
Which choice is pre-selected. |
maxExpiresAfter(int|Closure|null) |
admin.max_expires_after_minutes |
Ceiling for this placement. |
expiryPresets(array|Closure|null) |
admin.expiry_presets |
The buttons offered, in minutes. |
askForExpiry(bool|Closure) |
true |
false sends at the default with no field. |
ability(string|Closure|null) |
admin.ability |
Gate ability checked against the target user. |
icon(string|Closure|null) |
admin.icon, then icon |
Icon for this placement. Filament's own method. |
Everything Filament\Actions\Action offers works as usual — ->label(), ->icon(),
->visible(), ->authorize(), ->after(). To change the wording of the confirmations, override
->action().
The envelope is the default icon in all three placements, and in the modal. Change it once for the whole panel on the plugin, or per placement on the action:
MagicLoginPlugin::make()->adminIcon('heroicon-o-key') // action and modal, every placement MagicLoginPlugin::make()->icon('heroicon-o-key') // the login page's action too SendMagicLinkAction::make()->icon('heroicon-o-key') // this placement only
Pass false to either plugin method for no icon at all. adminIcon() follows icon() when you
have not set it, so an application that wants one icon everywhere says so once. Both take a
BackedEnum — Heroicon::Key — as readily as a string.
A name no icon set has falls back down that same chain rather than rendering: heroicon-s-email
is not a heroicon, and a typo like that would otherwise be a SvgNotFound — an error page on the
whole users table, from one string in a config file. That check runs on whatever the icon turns
out to be, including a name passed to Filament's own ->icon() on the action, and logs the
fallback once so the icon quietly being the envelope again is at least explained in laravel.log.
It covers this action only: a bad icon name on any other action in your table is still Filament's
error to throw.
Who may send a link
By default the action is visible to anyone who can already reach the row or the record page, which
means it inherits whatever your resource's canViewAny() / canView() and your panel's auth
middleware already decide. That is where an application with opinions about who may administer
users has already expressed them.
To require a policy ability as well:
// config/filament-magic-login.php 'admin' => ['ability' => 'sendMagicLink'],
// app/Policies/UserPolicy.php public function sendMagicLink(User $administrator, User $target): bool { return $administrator->hasRole('super-admin'); }
The default is not that, on purpose: Laravel's Gate denies an ability no policy defines, so
shipping 'sendMagicLink' as the default would make the action vanish in every application that
installed it.
If you run more than one panel, set admin.ability. ->panel('app'), ->panels([...]) and
->anyPanel() all let an administrator of one panel mint a login credential for another, which is
the feature working as intended and exactly the case worth gating.
Three guards are always on regardless. The action hides itself when the record is not something
that can be authenticated, when the record is the administrator's own row — somebody already
signed in has no use for a link to where they already are — and when the user fails
canAccessPanel() on the target panel. A link the panel would turn away at the door is not worth
offering, and there is nothing the administrator could do about it anyway.
SendMagicLinkToUser refuses that last one too, which is what covers access revoked between the
page being drawn and the modal being submitted.
Note that "the target panel" is the one the link is minted for, not the one you are looking at. A
user who cannot reach your admin panel but can reach your app panel gets no action on the admin
panel's users table by default — and does get one where you have said which panel to mint for:
SendMagicLinkAction::make()->panel('app')
One action, several panels
Naming one panel per placement only works when you know which panel each user belongs to. Where a
users table mixes them — staff in the admin panel, clients in app — hand the action a list
instead, best first. The link is minted for the first panel that row's user can actually reach:
SendMagicLinkAction::make()->panels(['admin', 'app'])
So a colleague gets an admin-panel link, a client gets an app one, and someone in both is sent
where you are standing, because admin comes first in the list. ->anyPanel() is the same over
every panel that registers the plugin, current panel first, for when you would rather not keep the
list up to date:
SendMagicLinkAction::make()->anyPanel()
Where the panel is not the one you are looking at, the confirmation modal says which one it is, so "send a login link" never quietly means a different door than the administrator expects. The per-send settings — the lifetime, the presets, the ceiling, the rate limit — are read from that panel too, not from the one you are on.
Two placements can still sit side by side where you want the choice to be yours rather than the recipient's, each showing only for the users its own panel would admit:
->recordActions([ SendMagicLinkAction::make('sendAdminLink')->panel('admin'), SendMagicLinkAction::make('sendAppLink')->panel('app')->label('Send an app login link'), ])
Every panel you name has to register the plugin — ->anyPanel() skips the ones that do not, since
that list is inferred rather than named. canAccessPanel() is called for each candidate panel,
once per row per action, so keep it cheap (or eager-load whatever it reads) on a large table.
A note on the link itself: the consume route is behind guest: middleware, so an administrator who
is signed in and clicks the link they just sent is redirected to the panel home without
consuming the token. That is intentional — a link should not silently swap an authenticated
session — but it does mean "open it yourself to check" is not a test. Open it in a private window,
or send it to a user who is signed out.
Configuration
Every option has a global default in config/filament-magic-login.php and a per-panel override
on the plugin. Panel-level setters win; anything you do not set falls back to config.
| Fluent method | Config key | Default | What it does |
|---|---|---|---|
expiresAfter(int|Closure $minutes) |
expires_after_minutes |
15 |
Link lifetime. |
position(MagicLinkPosition|Closure) |
position |
BelowForm |
Where the action renders. |
label(string|Closure|null) |
— | from translations | Action label. |
icon(string|BackedEnum|Closure|false|null) |
icon |
heroicon-o-envelope |
Icon on the login page's action. false removes it, and a name no icon set has falls back to the default. |
notification(class-string|Closure) |
notification |
MagicLinkNotification |
Notification used to deliver the link. |
rateLimit(int $maxAttempts, int $decaySeconds) |
rate_limit.* |
3 / 300 |
Limits link requests, keyed by email + IP. |
consumeRateLimit(int $maxAttempts, int $decaySeconds) |
consume_rate_limit.* |
10 / 60 |
Limits link consumption, keyed by IP. |
redirectTo(string|Closure|null) |
— | the panel URL | Where to land after a successful login. |
routePath(string|Closure) |
route_path |
magic-login |
Route segment under the panel path. |
invalidatePrevious(bool|Closure) |
invalidate_previous |
true |
Drop the user's other unused tokens for this panel when a new link is issued. |
honorRemember(bool|Closure) |
honor_remember |
true |
Carry the "remember me" checkbox into the magic-link session. |
useCustomLoginPage(bool|Closure) |
— | false |
Skip login page detection entirely. |
adminExpiresAfter(int|Closure|null) |
admin.expires_after_minutes |
falls back to expires_after_minutes |
Expiry pre-selected in the admin modal. |
expiryPresets(array|Closure) |
admin.expiry_presets |
[15, 60, 480, 1440, 4320] |
Toggle buttons offered there, in minutes. |
maxAdminExpiresAfter(int|Closure) |
admin.max_expires_after_minutes |
4320 (3 days) |
Ceiling for an admin-chosen expiry. |
adminRateLimit(int $maxAttempts, int $decaySeconds) |
admin.rate_limit.* |
10 / 60 |
Limits admin-issued links, keyed by the administrator. 0 disables it. |
adminAbility(string|Closure|null) |
admin.ability |
null |
Gate ability required to send a link. |
adminIcon(string|BackedEnum|Closure|false|null) |
admin.icon |
follows icon |
Icon on the "send a login link" action and its modal. false removes it. |
| — | queue |
true |
Send the shipped notification on the queue. |
| — | storage.driver |
database |
database or cache. Global only, not per panel. |
| — | blur_timing |
true |
Pad unknown-address responses so timing does not leak account existence. |
A fuller example:
MagicLoginPlugin::make() ->expiresAfter(minutes: 10) ->position(MagicLinkPosition::EmailFieldHint) ->label('Send me a login link') ->notification(MyMagicLinkNotification::class) ->rateLimit(maxAttempts: 5, decaySeconds: 600) ->redirectTo(fn () => route('filament.admin.pages.dashboard')) ->routePath('enlace') ->invalidatePrevious(false) ->honorRemember(false);
Two panels can run different settings at the same time — each panel gets its own plugin
instance and its own consume route (filament.{panelId}.magic-login.consume).
Using your own login page
If the panel already has a custom login page, add the trait to it:
use Arzcode\FilamentMagicLogin\Concerns\HasMagicLinkAction; use Filament\Auth\Pages\Login; class MyLogin extends Login { use HasMagicLinkAction; }
The plugin then leaves your page alone. If it finds a custom login page without the trait it
throws a LogicException at boot rather than silently replacing your page — either add the trait,
or call ->useCustomLoginPage() to opt out of the check entirely.
Registration order does not matter: ->login(MyLogin::class)->plugin(...) and
->plugin(...)->login(MyLogin::class) behave identically.
Customising the email
Any notification class works as long as it satisfies the constructor contract:
use Arzcode\FilamentMagicLogin\Contracts\MagicLinkNotification; use Illuminate\Notifications\Notification; class MyMagicLinkNotification extends Notification implements MagicLinkNotification { public function __construct( public string $url, public int $expiresAfterMinutes, public string $panelId, ) {} // ... }
The simplest route is to extend Arzcode\FilamentMagicLogin\Notifications\MagicLinkNotification,
which already implements the contract and gives you a translated MailMessage to tweak.
Queueing a custom notification is your call — implement ShouldQueue on it. The queue config
option only decides whether the shipped notification is queued.
Notifications are sent through Laravel's notification system, so a notifiable implementing
HasLocalePreference receives the email in its own locale automatically.
Events
Listen to these for audit logging:
| Event | Fired when |
|---|---|
MagicLinkRequested($user, $token, $panelId, $issuedBy) |
A link was issued and emailed. |
MagicLinkConsumed($user, $token, $panelId) |
A link was redeemed and the user signed in. |
MagicLinkRejected($reason, $email, $panelId, $ip) |
Anything was refused. |
$issuedBy is the administrator who sent the link, or null when the user asked for it
themselves on the login page — $event->wasIssuedByAdministrator() says which. It was added as an
optional fourth argument rather than as a separate event on purpose: a new event class would have
left every existing listener with an audit log quietly missing the more privileged half of its
entries.
The attribution lives on this event only, so MagicLinkConsumed cannot tell you a redeemed link
was admin-issued. If you need that, record it here keyed by $event->token->id and join on
consumption.
MagicLinkRejected reasons: rate_limited, unknown_user, cannot_access_panel, invalid,
expired, used. The first three are raised on the request side (where the UI deliberately
stays silent), the rest on the consume side.
Event::listen(MagicLinkRejected::class, function (MagicLinkRejected $event): void { Log::channel('security')->warning('magic link rejected', [ 'reason' => $event->reason, 'email' => $event->email, 'panel' => $event->panelId, 'ip' => $event->ip, ]); });
Storage
database (default)
Adds a magic_login_tokens table. Each row stores the token hash, the panel, the guard, the
remember flag, requesting IP and user agent, an expiry and a used_at timestamp — so you can
audit exactly which links were issued and redeemed.
cache
No migration at all. Entries are keyed by hash and expire on their own.
- Use a shared store — Redis, Memcached or the database cache.
arrayandfileare refused in production, because a multi-process deployment cannot share them. - Consumption is guarded by
Cache::lock(), so two simultaneous clicks cannot both win. - Consumed tokens are kept (marked used) for 24 hours so "already used" and "expired" stay distinguishable from "never existed" — after that the distinction is lost.
- No audit trail: listen to the events above if you need one. That applies to admin-issued links
too — who sent one is carried by
MagicLinkRequested, never by a stored row.
Set it globally in config; it is not a per-panel option.
'storage' => ['driver' => 'cache'],
Pruning (database driver)
Rows are kept for 24 hours after they expire, then become prunable. The install command offers to
schedule Laravel's pruner for you; to do it by hand, add this to routes/console.php:
use Illuminate\Support\Facades\Schedule; use Arzcode\FilamentMagicLogin\Models\MagicLoginToken; Schedule::command('model:prune', [ '--model' => [MagicLoginToken::class], ])->daily();
The cache driver needs no pruning.
Note that filament-magic-login:uninstall does not remove this — it reports the lines and leaves
them for you to delete, the same as any other code you wrote by hand.
Security notes
- Hashed at rest. Only a SHA-256 hash of the token is stored; the plaintext exists solely in the emailed URL. A fast hash is correct here — tokens are 64 random characters (~380 bits), not passwords — and it keeps lookup by hash possible without a separate selector column.
- Single-use. Consumption is a conditional update (
where used_at is null) on the database driver and a lock-guarded write on the cache driver, so a double-click or a prefetch race redeems the link exactly once. - Panel and guard bound. A token minted for one panel is rejected on every other panel, and a guard mismatch is rejected outright.
canAccessPanel()is honoured when the admin action is drawn, when a link is issued and when it is redeemed, so access revoked after a link was sent still blocks the login.- No user enumeration. Unknown addresses, users who cannot access the panel and rate-limited requests all produce the same confirmation message, and unknown addresses are padded in time.
- Rate limited on both sides: requests by email + IP, redemptions by IP.
- Existing users only. A magic link never creates an account.
- An admin-issued link is a credential. It is gated by whatever already decides who may reach
your user resource, plus
admin.abilitywhen you set one — and it is only ever emailed to the target user, never shown to the administrator. requested_ipandrequested_user_agentrecord the sender, so on an admin-issued row they describe the administrator rather than the recipient. That is the useful value: who asked for this link.- The two rate limits are separate. An administrator's sends are counted against the administrator, in their own key namespace, so they never eat into the recipient's own allowance for asking on the login page.
rememberis never carried into an admin-issued link. Whether a session persists is the recipient's own choice on the login form.- A queued notification means
Sentis "handed to the queue", not "delivered". Withqueue => true, a broken queue will still report success to the administrator.
Multi-factor authentication
A magic link does not trigger the 2FA challenge, by design. Receiving and clicking a link sent to the account's mailbox already proves possession of a second factor, so asking for a TOTP code on top would be a second challenge for the same guarantee.
Concretely: Filament 5 runs its multi-factor challenge inside the login page's authenticate()
method rather than in middleware, and this package authenticates through the panel guard directly.
Password logins on the same panel keep their challenge, untouched.
Bypassing the challenge does not bypass the enrolment requirement. A panel configured with
->multiFactorAuthentication([...], isRequired: true) still sends a magic-link user to the set-up
page until they have registered a second factor, exactly as it would after a password login. Both
behaviours are covered by tests.
If your threat model wants a TOTP code even after a mailbox-proven login, don't register this plugin on that panel — there is no option to re-add the challenge.
Email scanners
Outlook SafeLinks, Apple Mail Privacy Protection and similar scanners follow links before a human
does. HEAD requests are answered with 204 No Content without consuming the token, which covers
the common case. A scanner issuing a real GET will still burn the link; a confirmBeforeLogin()
option (GET shows a "Continue" button, POST consumes) is planned for a later release.
Testing
composer test # the whole suite against both storage drivers composer analyse # Larastan, level 6 composer format # Pint
Contributing
Pull requests are welcome. Please keep composer test, composer analyse and
composer format --test green, and add any new user-facing string to all three language files —
a test asserts they carry identical keys.
License
MIT. See LICENSE.

