Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Otp Laravel Package

erdemkeren/laravel-otp

A Laravel package for one-time password authentication. It generates and verifies short-lived OTP codes with configurable expiry, token length, and storage, making it easy to add secure OTP flows to login, registration, or sensitive actions.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up (about five minutes):

composer require erdemkeren/laravel-otp
php artisan vendor:publish --provider="Erdemkeren\Otp\OtpServiceProvider"
php artisan migrate

The publish step drops in two things you should read before writing any code:

  • config/otp.php — generator choice (numeric, string, uuid), code length, expiry, and the route prefix/middleware used by the package's built-in OTP entry screen.
  • database/migrations/*_create_otps_table.php — the otps table (morph relation to your user model, plus the code and timestamp columns).

Add the trait to your authenticatable model. This is the primary API surface — everything else hangs off it:

use Erdemkeren\Otp\Traits\HasOneTimePasswords;

class User extends Authenticatable
{
    use HasOneTimePasswords;
}

First use case: magic-code login. The shortest path from zero to a working flow is an email/SMS code that replaces the password field:

// Step 1 — issue
$otp = $user->createOneTimePassword();

Mail::to($user)->send(new LoginCodeMail($otp->code)); // or an SMS notification

return redirect()->route('otp.form', ['email' => $user->email]);

// Step 2 — verify
if (! $user->verifyOneTimePassword($request->input('code'))) {
    return back()->withErrors(['code' => 'That code is invalid or has expired.']);
}

Auth::login($user);

Once that works end to end, the second thing to look at is the otp middleware alias. Wrapping a route group with ->middleware('otp') is what makes this package different from just "generate a random number" — the middleware intercepts requests where no verified OTP exists and redirects to the package's entry route, so you get a reusable gate for whole sections of the app rather than hand-rolling the check in every controller.

Implementation Patterns

Interstitial 2FA on login. The cleanest pattern with this package is a three-route flow: POST /login validates credentials but does not call Auth::login(), instead issues an OTP and stores the pending user id in the session; GET /otp shows the form; POST /otp verifies and only then establishes the session. This keeps the "authenticated" boundary in exactly one place and avoids half-logged-in users.

Protecting route groups with the middleware. For sensitive areas (billing, API keys, account deletion) the middleware is far less code than per-action checks:

Route::middleware(['auth', 'otp'])->group(function () {
    Route::get('/billing', BillingController::class);
    Route::delete('/account', [AccountController::class, 'destroy']);
});

Because the check lives in the middleware stack, new routes added to the group are protected by default — the failure mode is "forgot to add the group", not "forgot to add the check".

Custom generators via config, not code. If you need a 4-digit PIN for SMS or a 40-char token for a link, swap the class in config/otp.php under the generators key rather than rewriting the issuance logic. The generator contract is a legitimate extension point — implement it and register it in config. Keep the default uuid/string generators for link-style tokens (long, unguessable) and numeric for typed codes.

Delivery via Notifications, not Mail facade calls in controllers. Wrap the send in a queued notification so the OTP creation request stays fast and retries are handled. On-demand notifications (Notification::route('sms', $phone)) work well here because the user is often not yet authenticated at issuance time.

Consume, don't just check. Always pair verification with invalidation (delete or mark verified) and issue a fresh code on every login attempt. Otherwise a code remains replayable for its whole TTL, and "one time" becomes "one time per window".

Throttle at the route level. The package generates and verifies; it does not own your rate limits. Add ->middleware('throttle:5,1') to both the issue and verify endpoints, and consider a cooldown (e.g. one code per 60 seconds per user) before calling createOneTimePassword() again — this also caps your SMS bill.

Testing. Fake the channel (Notification::fake()) and assert the notification was sent rather than asserting on the code. For expiry, $this->travel(11)->minutes() beats sleeping. Pull the code from the DB in tests when you need it:

$code = $user->oneTimePasswords()->latest()->first()->code;

If codes are hashed in your version, assert via verifyOneTimePassword() instead of reading the column.

Housekeeping. Expired rows accumulate silently. Add a scheduled otps:prune-style command, or a Model::where('created_at', '<', now()->subDay())->delete() in an existing cleanup job.

Gotchas and Tips

The Laravel version ceiling is the big one. Last release was August 2021, and the illuminate/* constraints almost certainly stop at Laravel 8. On Laravel 9+ Composer will refuse to install it, and --ignore-platform-reqs won't help because the block is a package dependency constraint, not a PHP extension. Plan for one of: pinning the app to an older Laravel, forking and widening the constraint yourself, or treating this package as reference source for a 50-line in-house implementation. Given the scope of what it does, the fork is usually a two-line composer.json edit plus a test run.

Six digits with no attempt counter is brute-forceable. A 6-digit numeric code is 10^6; if the TTL is 10 minutes and there's no throttle, that's a realistic attack from a single IP. The package's verification is a simple match — you own the lockout. Either add RateLimiter keyed on user id, or shorten the TTL aggressively.

Session dependency. The middleware redirect behaviour assumes a stateful, session-backed request (web group). For API-only or token-authenticated clients, the redirect-to-form flow is meaningless — call verifyOneTimePassword() from your own controller and return JSON instead of stacking the otp middleware.

Watch for code leakage in logs. Mail sent with MAIL_MAILER=log, Log::info($otp), dump($otp) left in a controller, or the code appearing in an API response all defeat the purpose. Grep the codebase for ->code before shipping.

Multiple active codes. Nothing stops several valid OTPs existing for one user. Decide explicitly whether createOneTimePassword() should first delete existing rows for that user (recommended for login flows) or coexist (fine for "confirm this action" prompts).

Config caching. After editing config/otp.php, run php artisan config:clear in local and remember that production deploys need config:cache — a stale cached config is a classic "why is it still 6 digits" bug.

Migration assumptions. The published migration morphs to the user model and assumes the default users table/primary key. If you use a non-standard auth model or UUID keys, edit the migration before running it (re-publishing after migrating means a manual diff).

Middleware alias collisions. If you already have an otp alias in app/Http/Kernel.php (some 2FA packages use the same name), you'll silently get the other one. Alias it as otp.protect or reference the FQCN directly.

Extension points worth knowing: the generator classes in config, the Otp model (bindable, so you can type-hint or override), and the middleware itself — subclassing it is the cleanest way to change the redirect target without forking the package.

Maturity note. 158 stars and a two-year-plus gap since release means you're the maintainer in practice: expect no upstream fixes for newer Laravel internals, and budget a review of the package source (it's small — a few hundred lines) before putting it in front of authentication.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource