jtargosz / laravel-action-otp
Confirm any action with a one-time code in Laravel 13: the action runs once, only after the code is verified
Requires
- php: ^8.3
- illuminate/cache: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/http: ^13.0
- illuminate/mail: ^13.0
- illuminate/notifications: ^13.0
- illuminate/routing: ^13.0
- illuminate/session: ^13.0
- illuminate/support: ^13.0
- illuminate/translation: ^13.0
- illuminate/validation: ^13.0
- illuminate/view: ^13.0
Requires (Dev)
- laravel/ai: ^1.0
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0|^12.0|^13.0
Suggests
- laravel/ai: Needed only if you want to expose OTP actions as AI tools
Provides
None
Conflicts
None
Replaces
None
README
Confirm any action with a one-time code. You describe the work in an action class, send a code by mail or SMS, and the action runs exactly once, only after the right code comes back.
Building with an AI assistant? The full API in plain text lives in
llms.txt. Point your agent at that file first.
Typical uses: registration without half-created users, password reset, email or phone change, login 2FA, "type the code to delete your account".
// Send: the action waits in cache, bound to this browser session ActionOtp::to($email)->for('password-reset')->send(new ResetPasswordAction($email, Hash::make($password)), $user); // Verify: runs handle() once and gives you its return value $result = ActionOtp::to($email)->for('password-reset')->verify($code);
Why this package
handle()runs exactly once, only after a correct code. Nothing is created or changed before that.- Each code is bound to the browser session or API token that asked for it, see Security.
- No database tables. Codes live in cache, with throttling, lockout, cooldown and expiry built in.
- Per action attributes,
ActionOtp::fake(), optional routes with a magic link and anotp.confirmmiddleware, mail in 8 languages.
Alternatives
| This package | spatie/laravel-one-time-passwords | benbjurstrom/otpz | tzsk/otp | |
|---|---|---|---|---|
| Built for | Confirming any action | Passwordless login | Passwordless login | Generating and checking codes |
| What runs after the code | Your action class | Login | Login | Nothing, you decide |
| Storage | Cache | Database | Database | Cache |
| Bound to | Session or challenge token | IP and user agent | Session | Nothing |
| UI | None, Blade examples below | Livewire component | Starter kit pages | None |
This is not TOTP (authenticator apps) and not a replacement for passkeys.
Requirements
- PHP 8.3, 8.4 or 8.5
- Laravel 13
- A persistent cache driver (file, database, redis, memcached). The array driver loses codes between requests, the null driver disables the package. The database driver needs the
cache_lockstable next to thecachetable. - Sessions for browser flows. API clients use the challenge token instead.
Install
composer require jtargosz/laravel-action-otp
Publish the config (optional):
php artisan vendor:publish --tag=action-otp-config
Quick start
Generate an action:
php artisan make:otp-action RegisterUserAction
Fill it in. The action waits in cache until the code is verified, so hash the password before you build it. Never keep a plain password in an action.
<?php namespace App\OtpActions; use App\Models\User; use Jtargosz\ActionOtp\Contracts\VerifiableAction; class RegisterUserAction implements VerifiableAction { public function __construct( public string $name, public string $email, public string $passwordHash, ) { } public function handle(): mixed { return User::create([ 'name' => $this->name, 'email' => $this->email, 'password' => $this->passwordHash, ]); } }
Send the code. These routes live in routes/web.php, so the code is bound to the visitor's session:
use App\OtpActions\RegisterUserAction; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Notification; use Jtargosz\ActionOtp\Facades\ActionOtp; Route::post('/register', function (Request $request) { $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:users,email'], 'password' => ['required', 'string', 'min:8'], ]); $email = strtolower($data['email']); ActionOtp::to($email)->for('register')->send( new RegisterUserAction($data['name'], $email, Hash::make($data['password'])), Notification::route('mail', $email) ); return response()->json(['message' => 'Code sent.']); })->middleware('throttle:5,1');
Verify the code. payload holds whatever handle() returned:
Route::post('/register/verify', function (Request $request) { $data = $request->validate([ 'email' => ['required', 'email'], 'code' => ['required', 'string'], ]); $result = ActionOtp::to(strtolower($data['email']))->for('register')->verify($data['code']); if (! $result->ok()) { return response()->json(['message' => $result->message], 422); } return response()->json(['user' => $result->payload]); })->middleware('throttle:10,1');
Keep a unique index on users.email. Request validation alone races under parallel registrations, the database is the final guard.
API
Every call starts with to(), which scopes the code to one identifier (email, phone or user id). Identifiers are compared exactly, so normalize them first, for example strtolower() emails. for() and withChallenge() are optional.
| Call | What it does | Returns |
|---|---|---|
to($identifier) |
Scopes to an identifier | the manager |
for($purpose) |
Scopes to a purpose, default default |
the manager |
withChallenge($token) |
Uses the token from send() instead of the session (API clients) |
the manager |
send($action, $notifiable) |
Stores the action, generates a code, sends the notification. Replaces only the pending code of the same session or token | sent + challenge, or throttled during cooldown or lockout |
verify($code) |
Checks the code, deletes it, runs handle() once |
verified + payload, or mismatch, empty, expired, throttled |
peek($code) |
Checks the code without running handle() and without deleting it |
matched, mismatch, empty, expired, throttled |
resend() |
New code (and link) for the pending action, same challenge | sent, empty, or throttled during cooldown or lockout |
clear() |
Deletes the pending code. Counters, lockout and cooldown stay | void |
Codes are strings. 012345 stays 012345, whitespace is trimmed, and letter codes are case-insensitive.
The result object:
$result->status; // OtpStatus: sent, matched, verified, empty, mismatch, throttled, expired, device_mismatch $result->message; // translated string for the status $result->payload; // mixed, set only when verified $result->challenge; // string, set only by send() $result->ok(); // true when verified $result->found(); // true when matched or verified $result->toArray(); // status, message, payload, challenge
The code is consumed before handle() runs, so a throwing handle() does not leave a reusable code. The user resends and tries again.
Browsers, API clients and purposes
Each send() creates a challenge, a random token that identifies this one pending action. The package looks it up in this order:
- the token passed to
withChallenge(), - the current browser session (set automatically by
send()), - for
#[AnyDevice]actions only, the last pending action of that identifier and purpose.
Without a challenge, verify() returns empty and the attempt does not count.
Browser flow: nothing to do. Send and verify from routes with the web middleware and the session carries the challenge.
API flow: return the challenge to the client and send it back when verifying:
// POST /api/login/code $result = ActionOtp::to($email)->for('login')->send(new ConfirmLoginAction($user->id), $user); return ['challenge' => $result->challenge]; // POST /api/login/verify $result = ActionOtp::to($email)->for('login')->withChallenge($request->input('challenge'))->verify($request->input('code'));
The challenge is a bearer secret for that one pending action, treat it like a short-lived token.
Purposes let one identifier have several pending actions. Each purpose has its own code, and a challenge from one purpose never works for another. Attempt counters, lockout and cooldown are shared by all purposes of an identifier.
ActionOtp::to($email)->for('register')->send($register, $notifiable); ActionOtp::to($email)->for('password-reset')->send($reset, $notifiable); // after the cooldown ActionOtp::to($email)->for('register')->verify($code);
Per action settings
Attributes on the action class override the config. They are inherited by subclasses.
use Jtargosz\ActionOtp\Attributes\AnyDevice; use Jtargosz\ActionOtp\Attributes\CodeFormat; use Jtargosz\ActionOtp\Attributes\CodeLength; use Jtargosz\ActionOtp\Attributes\SendWith; use Jtargosz\ActionOtp\Attributes\Ttl; #[Ttl(10)] #[SendWith(\App\Notifications\CodeSms::class)] class ResetPasswordAction implements VerifiableAction { /* ... */ } #[Ttl(30)] #[CodeFormat('alphanumeric')] #[CodeLength(8)] class ChangeEmailAction implements VerifiableAction { /* ... */ }
| Attribute | Overrides |
|---|---|
#[Ttl(minutes)] |
ttl_minutes |
#[CodeFormat('numeric' | 'alpha' | 'alphanumeric')] |
code_format |
#[CodeLength(1..64)] |
code_length |
#[SendWith(Notification::class)] |
notification |
#[AnyDevice] |
session binding, see below |
#[AnyDevice] lets the code and link work from any device, without the session or token. Use it only for low risk actions, such as confirming a newsletter address. Whoever triggers a send for an identifier replaces its pending AnyDevice action, so the owner could end up confirming an action someone else started. Never use it for password resets, email changes or anything that grants access.
Examples
Login with 2FA. Send to the User model as in the API flow above, ConfirmLoginAction::handle() returns the user id:
use Illuminate\Support\Facades\Auth; $result = ActionOtp::to($user->email)->for('login')->verify($code); if ($result->ok()) { Auth::loginUsingId($result->payload); }
Password reset. The password changes only after verify. Send it as in the snippet at the top of this file:
use App\Models\User; use Jtargosz\ActionOtp\Contracts\VerifiableAction; class ResetPasswordAction implements VerifiableAction { public function __construct(public string $email, public string $passwordHash) { } public function handle(): mixed { $user = User::where('email', $this->email)->firstOrFail(); $user->update(['password' => $this->passwordHash]); return $user; } }
Resend a fresh code when the message got lost. Honors the send cooldown:
$result = ActionOtp::to($email)->for('password-reset')->resend(); if ($result->status !== OtpStatus::Sent) { return response()->json(['message' => $result->message], 429); }
Check without consuming, for live validation of a single field:
$result = ActionOtp::to($email)->for('register')->peek($code); return match ($result->status) { OtpStatus::Matched => response()->json(['ok' => true]), OtpStatus::Throttled => response()->json(['message' => $result->message], 429), default => response()->json(['message' => $result->message], 422), };
Cancel the flow:
ActionOtp::to($email)->for('register')->clear();
Validate the code as a form field, without consuming it:
use Jtargosz\ActionOtp\Validation\ValidOtpCode; $request->validate([ 'email' => ['required', 'email'], 'code' => ['required', 'string', new ValidOtpCode($request->input('email'), 'register')], ]);
The third argument takes a challenge for API clients. The rule uses peek(): the code stays valid for verify(), but a wrong code counts as an attempt.
The code field. autocomplete="one-time-code" lets browsers offer the code from SMS:
<label for="code">{{ __('Verification code') }}</label> <input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" required aria-describedby="code-help"> <p id="code-help">{{ __('We sent it to your email.') }}</p> @error('code') <p role="alert">{{ $message }}</p> @enderror
For alpha or alphanumeric codes drop inputmode and pattern.
Routes, magic link and otp.confirm
Optional. Register the routes in routes/web.php:
use Jtargosz\ActionOtp\Facades\ActionOtp; ActionOtp::routes(); // prefix "otp", middleware ["web"] // ActionOtp::routes('api/otp', ['api']); // for token clients
| Route | Name | Input |
|---|---|---|
POST /otp/verify |
action-otp.verify |
identifier, code, purpose, challenge |
POST /otp/resend |
action-otp.resend |
identifier, purpose, challenge |
GET /otp/link |
action-otp.link |
signed, shows the link view |
POST /otp/link |
action-otp.link.store |
signed, verifies |
GET /otp/confirm |
action-otp.confirm |
auth, sends a code once per session and shows the confirm view |
POST /otp/confirm |
action-otp.confirm.store |
auth, code |
POST /otp/confirm/resend |
action-otp.confirm.resend |
auth |
All routes are rate limited to rate_limit requests per minute per IP.
By default verify and resend take the identifier from the request. To pick it on the server instead, register a resolver. The request field is then ignored and not required, and null answers empty without counting an attempt:
// AppServiceProvider::boot() ActionOtp::identifyUsing(fn (Request $request) => $request->session()->get('login.email'));
Responses: JSON requests get {status, message} with 200, 409 (device_mismatch), 422 or 429. The payload is never serialized. If handle() returns a Responsable or Response, it is sent as is. Browser requests redirect to the intended URL (fallback redirects.verified) with a status flash, or back with a code error. To change this, bind your own Jtargosz\ActionOtp\Contracts\VerifyResponse in a service provider.
Magic link
Set ACTION_OTP_LINK=true, register the routes and a view. The default mail then shows a button next to the code.
// AppServiceProvider::boot() ActionOtp::linkView('auth.otp-link');
{{-- resources/views/auth/otp-link.blade.php --}} <form method="POST" action="{{ $url }}"> @csrf <p>{{ __('Confirm this request?') }}</p> <button type="submit">{{ __('Confirm') }}</button> </form>
Opening the link only shows this page. Mail scanners that follow links consume nothing, the POST from the page does the work. The link works in the browser that started the flow. Anywhere else the user gets device_mismatch and types the code in the original browser instead, unless the action has #[AnyDevice]. A link that was already used also returns device_mismatch, since the browser no longer holds a pending code. An old link after resend() is a wrong link token: mismatch, and it counts as an attempt.
otp.confirm
Like password.confirm, but with a code sent to the logged-in user:
Route::get('/settings/security', SecurityController::class)->middleware(['auth', 'otp.confirm']); Route::delete('/account', DeleteAccountController::class)->middleware(['auth', 'otp.confirm']); // custom confirm route or timeout in seconds use Jtargosz\ActionOtp\Http\Middleware\RequireOtpConfirmation; Route::post('/billing', BillingController::class)->middleware(['auth', RequireOtpConfirmation::using(null, 300)]);
// AppServiceProvider::boot() ActionOtp::confirmView('auth.otp-confirm'); ActionOtp::confirmUsing(fn ($user) => $user->phone); // optional, default: email, then user id
{{-- resources/views/auth/otp-confirm.blade.php --}} <form method="POST" action="{{ $submitUrl }}"> @csrf <label for="code">{{ __('Code we just sent you') }}</label> <input id="code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" required> @error('code') <p role="alert">{{ $message }}</p> @enderror <button type="submit">{{ __('Confirm') }}</button> </form> <form method="POST" action="{{ $resendUrl }}"> @csrf <button type="submit">{{ __('Send a new code') }}</button> </form>
Unconfirmed JSON requests get 423, browser requests are redirected to /otp/confirm. After a correct code (or the magic link, when enabled) the confirmation lasts confirm_timeout seconds (default 900). Only GET requests are remembered as the intended URL, same as Laravel's password.confirm.
Notifications and SMS
The default notification is Jtargosz\ActionOtp\Mail\CodeMail: a translated markdown mail, queued and encrypted on the queue. It gets only an OtpMessage (code, expiry, purpose, link), never the action. Publish the view to restyle it:
php artisan vendor:publish --tag=action-otp-views
Your own notification receives the same OtpMessage:
use Jtargosz\ActionOtp\Support\OtpMessage; public function __construct(public OtpMessage $otp) { }
Set it globally with the notification config or per action with #[SendWith]. Notifications are sent in the current app locale unless the notifiable implements HasLocalePreference.
smsText() builds an SMS in the origin-bound one-time code format, so iOS and Android offer the code for autofill on your site only. The last line is @host #code. The host comes from sms_host, then app.url.
482913 is your Acme verification code.
@acme.com #482913
SMS example with Vonage
<?php namespace App\Notifications; use Illuminate\Notifications\Messages\VonageMessage; use Illuminate\Notifications\Notification; use Jtargosz\ActionOtp\Support\OtpMessage; class CodeSms extends Notification { public function __construct(public OtpMessage $otp) { } public function via(object $notifiable): array { return ['vonage']; } public function toVonage(object $notifiable): VonageMessage { return (new VonageMessage)->content($this->otp->smsText()); } }
Keep actions serializable. Most cache drivers serialize records, so avoid closures in action properties. If the action holds an Eloquent model, use the SerializesModels trait so handle() works on fresh data.
Config
File config/action-otp.php:
| Key | Env | Default | Meaning |
|---|---|---|---|
code_format |
ACTION_OTP_FORMAT |
numeric |
numeric, alpha or alphanumeric |
code_length |
ACTION_OTP_LENGTH |
6 |
Code length (1-64) |
ttl_minutes |
ACTION_OTP_TTL |
15 |
Code lifetime |
expired_grace_minutes |
ACTION_OTP_GRACE |
5 |
How long an expired code stays readable as expired (0 disables) |
max_attempts |
ACTION_OTP_ATTEMPTS |
5 |
Wrong tries before lock |
throttle_seconds |
ACTION_OTP_THROTTLE |
60 |
Lock duration (0 disables the lock) |
send_cooldown |
ACTION_OTP_COOLDOWN |
30 |
Min seconds between sends per identifier (0 disables) |
store_prefix |
ACTION_OTP_PREFIX |
action-otp: |
Cache key prefix |
notification |
CodeMail::class |
Notification class | |
channels |
['mail'] |
Channels of the default CodeMail |
|
sms_host |
ACTION_OTP_SMS_HOST |
null |
Host for the SMS autofill line, falls back to app.url |
link.enabled |
ACTION_OTP_LINK |
false |
Magic link in the default mail |
rate_limit |
ACTION_OTP_RATE_LIMIT |
10 |
Requests per minute per IP on the package routes |
confirm_timeout |
ACTION_OTP_CONFIRM_TIMEOUT |
900 |
Seconds an otp.confirm confirmation lasts |
redirects.verified |
/ |
Fallback redirect after verify on the package routes |
Security
- Each pending action is bound to the session or challenge token that created it. A send from another browser never replaces, reveals or unlocks it, so knowing someone's email is not enough to swap in your own action.
- Codes are compared with
hash_equals. Identifiers, purposes and challenges appear in cache keys and links only as SHA-256 hashes. - After
max_attemptswrong tries the identifier is locked forthrottle_seconds, across all purposes. Wrong tries count over a one hour window, across codes:send()andresend()never reset the count or lift the lock. Once the limit is reached, each further wrong try in that window locks again. Only a successful verify resets the counters, lockout and cooldown.clear()and expiry keep them, so a public "cancel" endpoint cannot lift the limits. - Every operation on an identifier runs under one lock, so parallel requests cannot run
handle()twice, slip past the attempt limit or skip the send cooldown. - A missing or wrong challenge returns
emptyand does not count as an attempt. Wrong codes do count per identifier, whichever session they come from, and sends share one cooldown per identifier. So someone who can start a flow for an identifier can trigger its lockout or cooldown (not bypass it). Protect your send routes with Laravel rate limiting. The examples in this file ship withthrottlemiddleware, keep it or tighten it. - The package
verifyandresendroutes trust theidentifierfrom the request unless you registerActionOtp::identifyUsing(). If these routes are public, take the identifier from the session or the logged-in user instead. - Statuses tell
emptyapart frommismatch. Within one session this shows whether a code is pending. If that matters, map both to one message. - The pending action waits in cache until verified, including any data you pass to it. Treat the cache as trusted storage, keep
ttl_minutesshort and hash passwords before they go into an action. - The default
CodeMailnever puts the action into the queue payload and is encrypted on the queue. Events carry no code and no action. - The magic link GET page consumes nothing, only the POST does, and it is signed and expires with the code.
#[AnyDevice]removes the session binding for that action. Read the warning in Per action settings.- Cache keys start with
store_prefix. If several apps share one cache backend, set a uniqueACTION_OTP_PREFIXper app.
Events
For logs and metrics, in Jtargosz\ActionOtp\Events:
CodeSent(identifier, purpose, expiresAt)CodeVerified(identifier, purpose, payload)CodeFailed(identifier, purpose)
Testing
ActionOtp::fake() keeps storage, sessions and throttling real, but records sends instead of delivering notifications:
use Jtargosz\ActionOtp\Facades\ActionOtp; public function test_registration_requires_the_code(): void { ActionOtp::fake('123456'); // omit the code to keep random codes $this->postJson('/register', ['name' => 'Ann', 'email' => 'ann@example.com', 'password' => 'secret123'])->assertOk(); ActionOtp::assertSent(RegisterUserAction::class, fn ($action, $message, $identifier, $purpose) => $identifier === 'ann@example.com'); $this->postJson('/register/verify', [ 'email' => 'ann@example.com', 'code' => ActionOtp::codeFor('ann@example.com', 'register'), ])->assertOk(); ActionOtp::assertVerified(RegisterUserAction::class); }
Assertions: assertSent, assertSentTimes, assertNotSent, assertNothingSent, assertVerified, assertNotVerified. Helpers: codeFor, challengeFor (for API flows), messageFor. In tests the array cache is fine, everything runs in one process.
Use with Laravel AI SDK
The package suggests laravel/ai but does not require it. Two tools are included. The identifier, purpose and challenge come from your code, the model only supplies the code:
Jtargosz\ActionOtp\Ai\ResendOtpToolresends the pending codeJtargosz\ActionOtp\Ai\CheckOtpToolchecks a code withpeek(), without running the action
composer require laravel/ai
public function tools(): iterable { return [ new \Jtargosz\ActionOtp\Ai\ResendOtpTool($this->user->email, 'login'), new \Jtargosz\ActionOtp\Ai\CheckOtpTool($this->user->email, 'login'), ]; }
Expose these tools only to authenticated agents acting for that user.
Translations
Ships with en, pl, it, es, de, fr, pt and nl: statuses, the mail and the SMS text. Messages follow the app locale. Publish with php artisan vendor:publish --tag=action-otp-lang, then edit or add lang/vendor/action-otp/{locale}/action-otp.php with the keys from en.
Upgrading from 1.x
2.0 has breaking changes. See UPGRADE.md.
Contributing
See CONTRIBUTING.md. Bug reports and small focused PRs are welcome.
License
MIT. See LICENSE.