afsakar/filament-otp-login
Filament OTP Login adds one-time-password authentication to Filament panels, enabling passwordless sign-in via temporary codes. It provides a simple, configurable flow for issuing and verifying OTPs, helping secure admin access without traditional passwords.
1. Confirm the version matrix before anything else. A 66-star package with a recent release date moves quickly, and Filament's v3 → v4 jump broke a lot of auth plugins. Check the README's compatibility table and the composer.json constraints, then pin: composer require afsakar/filament-otp-login:^1.0. If you're on Filament v4 and the package only claims v3, install it in a throwaway panel first rather than in your main app.
2. Install, publish, migrate.
composer require afsakar/filament-otp-login
php artisan vendor:publish --tag="filament-otp-login-config"
php artisan vendor:publish --tag="filament-otp-login-migrations"
php artisan migrate
Read the generated config file top to bottom — it is the actual documentation for this package. Note which storage driver it uses for codes (cache vs. a database column on users) because that decision drives most of the gotchas below.
3. Register the plugin on the panel you want to protect.
use Afsakar\FilamentOtpLogin\FilamentOtpLoginPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(FilamentOtpLoginPlugin::make());
}
Namespace and class name are the first thing to verify — php artisan about won't help here, so open vendor/afsakar/filament-otp-login/src/ and confirm the plugin class and its static make() signature if your IDE doesn't autocomplete it.
4. Fix mail before you fix anything else. OTP login is a mail-delivery feature wearing a login-form costume. Locally set MAIL_MAILER=log and tail storage/logs/laravel.log; the code will be sitting in the plaintext body. In staging, use Mailpit or a real transactional provider. If mail doesn't arrive, you will waste an hour debugging the plugin when the problem is SMTP.
5. First use case: an internal admin panel. The highest-value, lowest-risk adoption is a Filament panel used by staff whose email addresses you control. You get passwordless login, one less credential to reset, and you don't have to defend a public-facing signup flow. Public-facing customer login is a much bigger commitment — see the rate-limiting and enumeration notes below.
Where to look first when something is wrong: config/filament-otp-login.php, then the published migration, then src/Notifications/ (or wherever the mail class lives). Those three files explain 90% of behavior.
Replace vs. augment. The core decision is whether OTP replaces the password form or sits alongside it. Replacing is cleaner but locks out anyone whose mail is broken. A common compromise: keep the password form for super_admin, and route everyone else through OTP. Implement this by conditionally registering the plugin per panel, or by overriding the login page class on the panel — not by branching inside the plugin, which is rarely designed for it.
Per-panel enablement. If you run AdminPanelProvider and AppPanelProvider, register the plugin only where it belongs. Registering globally and then trying to disable it produces confusing redirect behavior because the login route is already claimed.
Brand the notification. The default email is almost certainly plain. Publish the notification class (check vendor/.../Notifications and whether the config exposes a notification key) and subclass it, or swap it via config. At minimum set a from address on a verified domain and put your product name in the subject — an unbranded "Your code is 482913" from [email protected] reads as phishing and trains users to ignore it.
Gate who can even request a code. Tie the request step to canAccessPanel() and to a domain allowlist:
// in the User model, or a small guard in a custom login page
public function canUseOtpLogin(): bool
{
return $this->canAccessPanel(Filament::getCurrentPanel())
&& str_ends_with($this->email, '@yourcompany.com');
}
Without this, an OTP form on a public panel becomes an email-enumeration oracle: submit an address, observe whether the response differs, and you've confirmed an account exists.
Local development shortcut. Don't disable the plugin in local — you'll ship bugs that only appear in production. Instead keep MAIL_MAILER=log and add a tiny Artisan helper that reads the last code from cache or the DB:
php artisan tinker --execute="dump(Cache::get('otp:'.auth()->id()));"
Or watch the log: tail -f storage/logs/laravel.log | grep -i code.
Test the flow, not the package. With Notification::fake() you can assert the mail was dispatched, then pull the code out of the fake notification and POST it to the verification route. This is the pattern that catches real regressions — expiry handling, resend throttling, wrong-code rejection — without depending on the package's internals:
Notification::fake();
$this->post('/admin/login', ['email' => $user->email]);
Notification::assertSentTo($user, OtpCodeNotification::class);
// extract the code from the notification payload, then:
$this->post('/admin/login/verify', ['code' => $code])
->assertRedirect('/admin');
Pair it with audit logging. On successful OTP login, dispatch your own Login event listener to record IP, user agent, and timestamp. Passwordless flows generate more "was that me?" support questions, not fewer, and you want an answer.
Tenancy. If you use Filament tenancy, the user is resolved before the tenant is known. Verify that a user in two tenants lands on the correct one — this is exactly the kind of integration the package's tests won't cover.
Queued notifications will silently break login. If the OTP mail implements ShouldQueue and no worker is running in staging, the request succeeds, the user sees "code sent," and nothing arrives. Either run a worker or force the notification to send synchronously. This is the #1 cause of "it works locally but not on the server."
Cache driver matters. If codes are stored in cache, an array or file store in a multi-server setup means the code written by server A isn't visible to server B during verification. Use Redis or the database. Also check that your cache isn't being flushed on deploy mid-flow.
Config caching hides your edits. After changing config/filament-otp-login.php in any non-local environment, run php artisan config:cache. Conversely, if your changes aren't taking effect locally, you probably cached the config earlier and forgot.
Resend throttling is your only abuse defense. Verify the cooldown actually works by hammering the resend button. If there's no throttle, add a RateLimiter::for('otp-request') keyed on email + IP in a service provider. Filament's built-in login throttling covers password attempts, not OTP requests — don't assume it transfers.
Codes are probably stored in plaintext. That's normal for short-lived OTPs, but it means anyone with DB read access (backups, read replicas, a shared staging DB) can impersonate users. Keep expiries short — five minutes is generous — and set otp_expires_at to be enforced server-side, not just in the UI countdown.
Compare emails case-insensitively. [email protected] and [email protected] are the same mailbox but different strings. If the package does a strict lookup, users who type their address differently get "no account found." Normalize on the way in if the package doesn't.
Handle the no-email and soft-deleted cases. A user with a null email, or a soft-deleted user whose email still resolves, will both produce confusing states. Scope your user lookup explicitly.
Watch for 2FA confusion. This is passwordless authentication, not a second factor. If stakeholders ask for "2FA," this package alone doesn't deliver it — you'd need it stacked behind a password, which is not the design.
Debugging checklist, in order: (1) Is mail arriving at all — check laravel.log and your provider's dashboard. (2) Is the notification dispatched — Notification::fake() in a tinker test. (3) Is the code stored where you think — inspect the cache key or DB column. (4) Is the clock/expiry sane — compare now() to the stored expiry. (5) Is the route even the plugin's — php artisan route:list --path=login.
Extension points are thin, so wrap rather than fork. For anything beyond config — custom validation, extra logging, a different verification UX — subclass the package's login page and point the panel at yours. Forking a 66-star package means you own the upgrade path forever, and Filament major versions will force one.
Pin the version and read the diff before upgrading. With a recent release date and a small maintainer base, minor bumps can carry auth-flow changes. Treat upgrades like a Laravel minor: changelog, then a manual login test in staging.
How can I help you explore Laravel packages today?