One-time passwords sent via SMS remain the most widely used method for phone number verification, login confirmation, and transaction authorization. This guide walks through building a complete OTP verification system in Laravel.
Create a dedicated service for OTP generation, storage, and verification:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class OtpService
{
public function __construct(
protected int $length = 6,
protected int $ttl = 300, // 5 minutes
protected int $maxAttempts = 5,
) {}
public function generate(string $identifier): string
{
$code = (string) random_int(
10 ** ($this->length - 1),
(10 ** $this->length) - 1
);
Cache::put(
$this->key($identifier),
[
'code' => $code,
'attempts' => 0,
'created_at' => now(),
],
$this->ttl
);
return $code;
}
public function verify(string $identifier, string $code): bool
{
$record = Cache::get($this->key($identifier));
if (! $record) {
return false; // Expired or non-existent
}
if ($record['attempts'] >= $this->maxAttempts) {
Cache::forget($this->key($identifier));
return false; // Too many attempts
}
Cache::put(
$this->key($identifier),
array_merge($record, ['attempts' => $record['attempts'] + 1]),
$this->ttl
);
if (! hash_equals((string) $record['code'], (string) $code)) {
return false; // Code mismatch
}
Cache::forget($this->key($identifier));
return true;
}
public function exists(string $identifier): bool
{
return Cache::has($this->key($identifier));
}
public function invalidate(string $identifier): void
{
Cache::forget($this->key($identifier));
}
protected function key(string $identifier): string
{
return "otp:{$identifier}";
}
}
---
## Step 2: OTP SMS Notification
```bash
php artisan make:notification SendOtp
---
```php
<?php
namespace App\Notifications;
use App\Services\OtpService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\VonageMessage;
use Illuminate\Notifications\Notification;
class SendOtp extends Notification implements ShouldQueue
{
use Queueable;
public string $code;
public function __construct(
protected string $identifier,
protected string $purpose = 'verification',
) {
$this->code = app(OtpService::class)->generate($this->identifier);
}
public function via(object $notifiable): array
{
return ['vonage'];
}
public function toVonage(object $notifiable): VonageMessage
{
return (new VonageMessage)
->content("Your {$this->purpose} code is: {$this->code}. It expires in 5 minutes.")
->unicode();
}
}
---
## Step 3: Request OTP Controller
```php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Notifications\SendOtp;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class OtpController extends Controller
{
public function send(Request $request): RedirectResponse
{
$request->validate([
'phone' => 'required|phone:US,GB,CA',
]);
$key = "otp-send:{$request->phone}";
if (RateLimiter::tooManyAttempts($key, 3)) {
$seconds = RateLimiter::availableIn($key);
return back()->withErrors([
'phone' => "Too many attempts. Try again in {$seconds} seconds.",
]);
}
RateLimiter::hit($key, 60);
$user = User::where('phone', $request->phone)->firstOrFail();
$user->notify(new SendOtp($request->phone, 'login'));
return back()->with('status', 'OTP sent to your phone.');
}
}
---
## Step 4: Verify OTP Controller
```php
<?php
namespace App\Http\Controllers;
use App\Services\OtpService;
use Illuminate\Http\RedirectRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class OtpVerificationController extends Controller
{
public function __construct(
protected OtpService $otp
) {}
public function verify(Request $request): RedirectRequest
{
$request->validate([
'phone' => 'required',
'code' => 'required|string|size:6',
]);
if (! $this->otp->verify($request->phone, $request->code)) {
return back()->withErrors([
'code' => 'Invalid or expired code.',
]);
}
$user = User::where('phone', $request->phone)->first();
if ($user) {
Auth::login($user);
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
return redirect()->route('register', ['phone' => $request->phone]);
}
}
---
## Step 5: Routes
```php
use App\Http\Controllers\OtpController;
use App\Http\Controllers\OtpVerificationController;
Route::post('/otp/send', [OtpController::class, 'send'])
->middleware('throttle:3,1')
->name('otp.send');
Route::post('/otp/verify', [OtpVerificationController::class, 'verify'])
->middleware('throttle:5,1')
->name('otp.verify');
---
## Step 6: Blade Form
```blade
<form method="POST" action="{{ route('otp.send') }}">
@csrf
<input type="tel" name="phone" placeholder="Phone number" required>
<button type="submit">Send OTP</button>
</form>
<form method="POST" action="{{ route('otp.verify') }}">
@csrf
<input type="hidden" name="phone" value="{{ $phone ?? old('phone') }}">
<input type="text" name="code" placeholder="Enter 6-digit code" maxlength="6" required>
<button type="submit">Verify</button>
</form>
---
## Rate Limiting & Security
### Multi-Layer Rate Limiting
Apply limits at multiple levels:
```php
// 1. Global route throttle
Route::post('/otp/send', [OtpController::class, 'send'])
->middleware('throttle:3,1');
// 2. Per-user rate limiter (in controller)
RateLimiter::hit("otp-send:{$request->phone}", 60);
// 3. Per-IP rate limiter
RateLimiter::hit("otp-send:{$request->ip()}", 60);
// 4. Max attempts per code (in OtpService)
protected int $maxAttempts = 5;
---
### Security Best Practices
| Practice | Implementation |
|---|---|
| **Use timing-safe comparison** | `hash_equals()` instead of `==` |
| **Expire codes** | Cache TTL of 5 minutes |
| **Limit attempts** | Max 5 attempts per code, then invalidate |
| **Rate limit sending** | Max 3 send requests per 60 seconds |
| **Use HTTPS** | Encrypt OTP in transit |
| **No logging** | Never log OTP codes |
### Example: Timing-Safe Comparison
```php
// Correct
if (! hash_equals((string) $record['code'], (string) $code)) {
return false;
}
// Incorrect (vulnerable to timing attack)
if ($record['code'] !== $code) {
return false;
}
---
## Resend Functionality
```php
public function resend(Request $request): RedirectResponse
{
$request->validate(['phone' => 'required|phone:US,GB,CA']);
$key = "otp-resend:{$request->phone}";
if (RateLimiter::tooManyAttempts($key, 2)) {
$seconds = RateLimiter::availableIn($key);
return back()->withErrors([
'phone' => "Please wait {$seconds} seconds before requesting a new code.",
]);
}
RateLimiter::hit($key, 120);
// Invalidate old OTP
app(OtpService::class)->invalidate($request->phone);
// Send new OTP
$user = User::where('phone', $request->phone)->firstOrFail();
$user->notify(new SendOtp($request->phone, 'login'));
return back()->with('status', 'New OTP sent.');
}
---
## Testing OTP Verification
```php
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Notifications\SendOtp;
use App\Services\OtpService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class OtpVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_request_otp(): void
{
Notification::fake();
$user = User::factory()->create(['phone' => '+15551234567']);
$this->post('/otp/send', ['phone' => $user->phone])
->assertSessionHas('status');
Notification::assertSentTo($user, SendOtp::class);
}
public function test_otp_verification_succeeds(): void
{
$phone = '+15551234567';
$service = app(OtpService::class);
$code = $service->generate($phone);
User::factory()->create(['phone' => $phone]);
$this->post('/otp/verify', [
'phone' => $phone,
'code' => $code,
])->assertRedirect('/dashboard');
}
public function test_expired_otp_fails(): void
{
$phone = '+15551234567';
$this->post('/otp/verify', [
'phone' => $phone,
'code' => '000000',
])->assertSessionHasErrors('code');
}
public function test_otp_rate_limiting(): void
{
$user = User::factory()->create(['phone' => '+15551234567']);
for ($i = 0; $i < 3; $i++) {
$this->post('/otp/send', ['phone' => $user->phone]);
}
$this->post('/otp/send', ['phone' => $user->phone])
->assertSessionHasErrors('phone');
}
}
---
## Full Request Flow Diagram
---
Client Laravel Cache SMS Provider
| | | |
|--- POST /otp/send ----->| | |
| |--- Rate limit check --->| |
| |<-- OK ------------------| |
| | | |
| |--- Store OTP ---------->| |
| | | |
| |--- Dispatch queue ----->| |
| | |--- Send SMS ----------->|
|<-- "OTP sent" ---------| | |
| | | |
|--- POST /otp/verify --->| | |
| |--- Retrieve OTP ------->| |
| |<-- Code + attempts -----| |
| | | |
| |--- hash_equals() -------| |
|<-- Success/Failure -----| | |
---
## Next Steps
Now that OTP verification is in place, consider adding passwordless login, two-factor authentication, and phone-based account recovery. These patterns follow the same OTP foundation.
For more on SMS delivery infrastructure — including queueing and webhook handling — see the [Laravel SMS notifications using queues](/blog/laravel-sms-notifications-queues) guide.
## Related Articles
- [How to Send SMS in Laravel (Step-by-Step Guide)](/blog/how-to-send-sms-in-laravel)
- [Laravel SMS Notifications Using Queues (Performance Guide)](/blog/laravel-sms-notifications-queues)
- [Ultimate Guide to Laravel SMS Integration (2026)](/blog/ultimate-guide-laravel-sms-integration)
- [Best SMS APIs for Laravel in 2026](/blog/best-sms-apis-for-laravel)