jtargosz / laravel-action-otp
Action based one time codes for Laravel 13
Requires
- php: ^8.3
- illuminate/cache: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/notifications: ^13.0
- illuminate/support: ^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
Action based one time codes for Laravel 13. Each code is linked to an action class, and the action runs only after the code is verified.
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, login 2FA, password reset, phone confirmation.
Why this package
- No database tables. Codes live in cache and clean up after themselves.
- The action runs exactly once, only after a correct code. No half created users.
- Throttling and expiry built in, no extra setup.
- Works with any notifiable: User model, mail route, SMS channel.
- Translated messages in 8 languages.
- Optional validation rule and AI tools included.
Contents
- How it works
- Why this package
- Requirements
- Install
- Quick start
- API
- Examples
- Config
- Notifications
- Security
- Testing
- Use with Laravel AI SDK
- Translations
- Contributing
- License
How it works
- You create an action class with a
handle()method. It holds the data and the work to do. - You send a code with
ActionOtp::to($id)->send($action, $notifiable). - The user types the code.
verify($code)checks it, runshandle()once and deletes the code.
The code never runs the action before verification. Wrong codes are throttled, expired codes are rejected.
flowchart LR
A[Create action class] --> B[send: store + notify]
B --> C{verify code}
C -->|match| D[run handle once]
C -->|wrong| E[count attempt]
E --> C
C -->|too many| F[throttled]
D --> G[code deleted]
Loading
Note: this package sends short codes by mail or SMS and runs a follow-up action. It 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.
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:
<?php namespace App\OtpActions; use App\Models\User; use Illuminate\Support\Facades\Hash; use Jtargosz\ActionOtp\Contracts\VerifiableAction; class RegisterUserAction implements VerifiableAction { public function __construct( public string $name, public string $email, public string $password, ) { } public function handle(): mixed { return User::create([ 'name' => $this->name, 'email' => $this->email, 'password' => Hash::make($this->password), ]); } }
Send the code:
use App\OtpActions\RegisterUserAction; 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'], ]); ActionOtp::to($data['email'])->send( new RegisterUserAction($data['name'], $data['email'], $data['password']), Notification::route('mail', $data['email']) ); return response()->json(['message' => 'Code sent.']); })->middleware('throttle:5,1');
Keep a unique index on users.email. Request validation alone races under parallel registrations, the database is the final guard.
Verify the code. payload holds whatever handle() returned:
use Jtargosz\ActionOtp\Facades\ActionOtp; Route::post('/verify', function (Request $request) { $data = $request->validate([ 'email' => ['required', 'email'], 'code' => ['required', 'string'], ]); $result = ActionOtp::to($data['email'])->verify($data['code']); if (! $result->ok()) { return response()->json(['message' => $result->message], 422); } return response()->json(['user' => $result->payload]); })->middleware('throttle:10,1');
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, or User@x.com and user@x.com get separate codes:
| Call | What it does | Returns |
|---|---|---|
send($action, $notifiable) |
Stores the action, generates a code, sends the notification. Replaces any pending code for the identifier | sent, or throttled during cooldown |
verify($code) |
Checks the code, runs handle() once, deletes the code |
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() |
Generates a new code for the stored action and sends it again | sent, empty or throttled |
clear() |
Deletes the code, throttle counters and send cooldown | void |
The result object:
$result->status; // OtpStatus enum: sent, matched, verified, empty, mismatch, throttled, expired $result->message; // translated string for the status $result->payload; // mixed, set only when verified $result->ok(); // true when verified $result->found(); // true when matched or verified
The code is consumed before handle() runs, so a throwing handle() does not leave a reusable code. The user resends and tries again.
Examples
Login with 2FA. Send to the User model, log in after verify:
use Illuminate\Support\Facades\Auth; use Jtargosz\ActionOtp\Facades\ActionOtp; ActionOtp::to($user->email)->send(new ConfirmLoginAction($user->id), $user); $result = ActionOtp::to($user->email)->verify($code); if ($result->ok()) { Auth::loginUsingId($result->payload); }
Password reset. The password changes only after verify:
use App\Models\User; use Illuminate\Support\Facades\Hash; use Jtargosz\ActionOtp\Contracts\VerifiableAction; class ResetPasswordAction implements VerifiableAction { public function __construct(public string $email, public string $password) { } public function handle(): mixed { $user = User::where('email', $this->email)->firstOrFail(); $user->update(['password' => Hash::make($this->password)]); return $user; } } ActionOtp::to($email)->send( new ResetPasswordAction($email, $password), Notification::route('mail', $email) );
Resend a fresh code when the message got lost. Honors the send cooldown:
$result = ActionOtp::to($email)->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)->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, for example on logout:
ActionOtp::to($email)->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'))], ]);
The rule uses peek(), so the code stays valid until you call verify(). A wrong code in the rule counts as an attempt, same as peek(), so a failed validation followed by a failed verify() burns two tries.
Config
File config/action-otp.php:
| Key | Default | Meaning |
|---|---|---|
code_format |
numeric |
numeric, alpha or alphanumeric |
code_length |
6 |
Code length |
ttl_minutes |
15 |
Code lifetime |
expired_grace_minutes |
5 |
How long an expired code stays readable as expired (0 disables) |
max_attempts |
5 |
Wrong tries before lock |
throttle_seconds |
60 |
Lock duration (0 disables the lock) |
send_cooldown |
30 |
Min seconds between sends (0 disables) |
store_prefix |
action-otp: |
Cache key prefix |
notification |
CodeMail::class |
Notification class |
channels |
['mail'] |
Delivery channels |
Env keys: ACTION_OTP_FORMAT, ACTION_OTP_LENGTH, ACTION_OTP_TTL, ACTION_OTP_GRACE, ACTION_OTP_ATTEMPTS, ACTION_OTP_THROTTLE, ACTION_OTP_COOLDOWN, ACTION_OTP_PREFIX.
Notifications
The default mail is Jtargosz\ActionOtp\Mail\CodeMail. To use your own class, set it in config. It receives the full record in the constructor:
public function __construct(protected array $record)
Record keys: action, notifiable, code, expires_at. Your class can send mail, SMS or push.
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.
SMS example with Vonage
<?php namespace App\Notifications; use Illuminate\Notifications\Messages\VonageMessage; use Illuminate\Notifications\Notification; class CodeSms extends Notification { public function __construct(protected array $record) { } public function via(object $notifiable): array { return ['vonage']; } public function toVonage(object $notifiable): VonageMessage { return (new VonageMessage) ->content('Your code: '.$this->record['code']); } }
'notification' => App\Notifications\CodeSms::class,
Security
- Codes are compared with
hash_equals. - The identifier is stored as SHA-256, never plain text in the cache key.
- Cache keys start with
store_prefix. If several apps share one cache backend, set a uniqueACTION_OTP_PREFIXper app, otherwise they read and overwrite each other's codes and counters. - After
max_attemptswrong tries the identifier is locked forthrottle_seconds. - Sends are limited by
send_cooldown. Protect your send and resend routes with Laravel rate limiting as well. The route examples in this file ship withthrottlemiddleware, keep it or tighten it. - Statuses tell
emptyapart frommismatch, so a caller can probe whether an identifier has a pending code. If identifiers are sensitive in your app, put these routes behind auth or rate limiting. verify()consumes the code under an atomic lock, so two parallel requests cannot runhandle()twice.- 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 avoid stuffing secrets you do not need. CodeSentcarries the full record, including the action payload. If you log event payloads (Telescope does by default), exclude this event or keep secrets out of the action.- Expired codes are deleted on first use and return status
expired. - Events
CodeSent,CodeVerifiedandCodeFailedare dispatched for logs and metrics.
Testing
Use array cache and fake notifications:
Notification::fake(); ActionOtp::to('test@example.com')->send( new RegisterUserAction('A', 'test@example.com', 'secret123'), Notification::route('mail', 'test@example.com') );
Read the code in a test through the vault:
$record = app(Jtargosz\ActionOtp\Contracts\StoresCodes::class) ->scope('test@example.com')->get();
Use with Laravel AI SDK
The package suggests laravel/ai but does not require it. Two tools are included for agents:
Jtargosz\ActionOtp\Ai\SendOtpToolJtargosz\ActionOtp\Ai\VerifyOtpTool
composer require laravel/ai
public function tools(): iterable { return [ new \Jtargosz\ActionOtp\Ai\SendOtpTool, new \Jtargosz\ActionOtp\Ai\VerifyOtpTool, ]; }
Expose these tools only to authenticated, trusted agents. A model with these tools can send codes and probe them within the throttle limits, so never attach them to a public or anonymous agent.
Translations
Ships with en, pl, it, es, de, fr, pt and nl. Publish with tag action-otp-lang and edit as needed. To add a language, copy lang/en/action-otp.php to lang/{locale}/action-otp.php with the same keys.
Contributing
See CONTRIBUTING.md. Bug reports and small focused PRs are welcome.
License
MIT. See LICENSE.