Laravel OTP Verification via SMS (Complete Guide)

Laravel OTP Verification via SMS (Complete Guide)
Laravel SMS Team
Laravel SMS Team
June 19, 2026

Laravel OTP Verification via SMS (Complete Guide)

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.

Common OTP Use Cases

  • Phone verification during user registration
  • Passwordless login via SMS code
  • Two-factor authentication on sensitive actions
  • Transaction confirmation for payments or withdrawals
  • Account recovery via phone number

Architecture Overview


User submits phone → Generate OTP → Store in cache ↓ Send via SMS notification ↓ User enters code → Retrieve from cache → Verify hash ↓ Success/Failure

Step 1: OTP Service Class

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)