benbjurstrom/otpz
otpz is a Laravel package for passwordless authentication using one-time passwords. Generate and validate secure OTP codes, send them by email, and add rate limiting and expiry controls to your login flow with minimal setup.
Install
composer require benbjurstrom/otpz
php artisan otpz:install # publishes config + migration (confirm the exact command in the README)
php artisan migrate
If the install command isn't available, publish manually and list the tags first:
php artisan vendor:publish --provider="BenBjurstrom\Otpz\OtpzServiceProvider"
Wire up the user model
Add the package trait to the model that should be able to log in by code:
use BenBjurstrom\Otpz\Concerns\OtpAuthenticatable;
class User extends Authenticatable
{
use OtpAuthenticatable;
}
Where to look first (in this order)
config/otpz.php — code length, expiry window, max attempts, notification class, user model / identifier field. This file drives almost every behaviour you'll want to change.ip_address column is nullable, so a request without a resolvable IP no longer breaks issuance. Also check the indexes; your verification lookups depend on them.Otp model — expiry/redemption helpers live here; read it before writing your own expiry logic.Otpz::send(), Otpz::attempt() or equivalent) — this is the whole public API surface, and it's small.resources/lang / lang vendor files — new in v0.7.0, see the localization note below.First use case: passwordless email login
Two endpoints and one screen:
POST /login/otp — accept an email, call the facade to issue and mail a code, redirect to the code form.POST /login/otp/verify — accept the code, attempt verification, Auth::login($user), regenerate the session, redirect to the dashboard.Do not build the code-entry screen until you've confirmed which exception/return value a failed attempt produces — you'll want to map it to a single generic "invalid or expired code" message rather than surfacing the package's internal reason.
Thin controller, Form Request validation. Keep the package calls in the controller and push identifier/code validation into Form Requests. The package validates expiry and attempt counts; your Form Request should only validate shape (email, digits:6). Don't duplicate expiry logic in two places.
Rate limit both halves of the flow. Issuance is the expensive half (it sends mail). Wrap the send route in a throttle: middleware keyed by email and IP, and separately cap verification attempts. The package's internal attempt counter is per-record, so it does not protect you against an attacker requesting a fresh code every second.
Step-up authentication. The same primitives work for "confirm it's you" gates on sensitive actions — changing email, rotating API keys, viewing billing. Issue a code, verify, then stamp session(['otp_verified_at' => now()]) and check it in a middleware. This is usually a better second use case than login, because it doesn't touch your auth guards.
Custom notification. Point config('otpz.notification') (or equivalent) at your own class extending the package notification so you can control the mailer, subject, markdown template, and branding without forking. Assert on it in tests to keep the contract stable.
Localization (new in v0.7.0). Publish the lang files and translate the notification copy per locale. Two things to get right:
$notification->locale($user->locale) on the mailable/notification instance over mutating app()->setLocale(), which leaks across a request.Cleanup. OTP rows are transient. Either put Pruneable/MassPrunable on the model and schedule model:prune, or schedule a delete where expires_at < now() task. Left alone the table grows forever and slows the verification lookup.
Testing. Notification::fake() plus asserting the notification was dispatched to the right address covers the send path cheaply. For verification, either insert an Otp row directly with a known code (works only if codes are stored recoverably — check the migration) or use the package's factory if one is published. Assert the negative paths explicitly: expired code, wrong code, code already consumed, code belonging to a different identifier.
Integration tips. Emit your own domain event after a successful verification so audit logging, "new device" emails, and analytics don't get welded into the login controller. Keep the package's routes out of your app if you need custom redirects — write your own controller and call the facade rather than overriding published route files.
ip_address is nullable as of v0.7.0 — but only in the newly published migration. If you installed on 0.6.x, republishing won't modify your existing table. Add your own migration:
Schema::table('otps', fn (Blueprint $t) => $t->string('ip_address')->nullable()->change());
Without it, issuance fails or silently records an empty string whenever the request IP can't be resolved (CLI, health checks, some proxies).
IP capture is only as good as your proxy config. Behind a load balancer without TrustProxies configured, every OTP row records the balancer's IP. That poisons per-IP rate limiting (everyone shares one bucket → mass lockout) and your audit trail. Configure trusted proxies before relying on ip_address for anything security-relevant.
Delivery latency vs. expiry. The expiry window starts at row creation, not at inbox delivery. With a queued notification and a busy worker, a five-minute window can leave users with an already-dead code. Either send the notification synchronously, give the queue priority, or widen the window — and make the "expired" error message tell the user to request a new code rather than just failing.
Don't leak account existence. Return identical responses and timings whether or not the email exists. If the package throws for unknown identifiers, catch it at the controller boundary and return the generic success response.
Config caching. config/otpz.php changes don't apply under config:cache. php artisan config:clear is the first thing to try when a config change "doesn't work."
Laravel 13 support (v0.7.0) is a constraint widening — nothing to do if you're on 10/11/12. It does mean the package now has to satisfy a wider framework matrix; if you hit a framework-version-specific bug, check the CI matrix before assuming it's your code.
Codes in the database. The migration persists codes in the otps table. Treat that table as a secret store: restrict access, never log the column, and prune aggressively. If you find yourself wanting to display a code for debugging, use a mail catcher (Mailpit/Log driver) instead.
Extend via config, not forks. Notification class, user model, and identifier column are all config-driven. The most common regression in this kind of package is a developer patching vendor code to change an email subject, then losing the patch on composer update. Check config first — there is almost always a hook.
Attempt counters reset on new issuance. A user who exhausts attempts can simply request a new code. That's correct behaviour; just make sure your outer throttle (see above) is the thing actually protecting you, and that requesting a new code invalidates prior outstanding codes if you want strict single-active-code semantics.
How can I help you explore Laravel packages today?