Phone number verification via SMS is a critical security feature for modern web applications. It prevents fake registrations, enables two-factor authentication, and builds trust with users. This guide shows you how to implement a complete phone verification system in Laravel.
First, add phone number fields to your users table:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 20)->unique()->nullable()->after('email');
$table->timestamp('phone_verified_at')->nullable()->after('phone');
$table->string('verification_code', 6)->nullable();
$table->timestamp('verification_code_sent_at')->nullable();
$table->unsignedTinyInteger('verification_attempts')->default(0);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'phone',
'phone_verified_at',
'verification_code',
'verification_code_sent_at',
'verification_attempts',
]);
});
}
};
---
Create a dedicated verification codes table for better scalability:
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('phone_verifications', function (Blueprint $table) {
$table->id();
$table->morphs('verifiable');
$table->string('phone', 20);
$table->string('code', 6);
$table->timestamp('expires_at');
$table->timestamp('verified_at')->nullable();
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamps();
});
}
};
---
## Creating the VerifiedPhone Middleware
Build middleware to protect routes that require a verified phone:
```php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifiedPhone
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || !$user->phone_verified_at) {
return $request->expectsJson()
? response()->json(['message' => 'Phone number not verified.'], 403)
: redirect()->route('verification.phone.notice');
}
return $next($request);
}
}
---
Register it in `bootstrap/app.php`:
```php
<?php
use App\Http\Middleware\VerifiedPhone;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'phone.verified' => VerifiedPhone::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
---
Apply it to routes:
```php
Route::middleware(['auth', 'phone.verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);
});
---
## Generating and Sending Verification Codes
Create a service to handle code generation and SMS sending:
```php
<?php
namespace App\Services;
use App\Models\PhoneVerification;
use App\Models\User;
use Illuminate\Support\Str;
class PhoneVerificationService
{
public function generateCode(): string
{
return (string) random_int(100000, 999999);
}
public function sendCode(User $user): PhoneVerification
{
// Invalidate previous codes
PhoneVerification::where('verifiable_id', $user->id)
->where('verifiable_type', get_class($user))
->whereNull('verified_at')
->update(['expires_at' => now()]);
$code = $this->generateCode();
$verification = PhoneVerification::create([
'verifiable_id' => $user->id,
'verifiable_type' => get_class($user),
'phone' => $user->phone,
'code' => $code,
'expires_at' => now()->addMinutes(10),
]);
$user->notify(new PhoneVerificationCode($code));
$user->update([
'verification_code' => $code,
'verification_code_sent_at' => now(),
'verification_attempts' => 0,
]);
return $verification;
}
public function verify(User $user, string $code): bool
{
$verification = PhoneVerification::where('verifiable_id', $user->id)
->where('verifiable_type', get_class($user))
->where('code', $code)
->whereNull('verified_at')
->where('expires_at', '>', now())
->first();
if (!$verification) {
$user->increment('verification_attempts');
return false;
}
$verification->update(['verified_at' => now()]);
$user->update([
'phone_verified_at' => now(),
'verification_code' => null,
'verification_attempts' => 0,
]);
return true;
}
}
---
The notification that sends the SMS:
```php
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class PhoneVerificationCode extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public string $code
) {}
public function via(object $notifiable): array
{
return ['sms'];
}
public function toSms(object $notifiable): string
{
return "Your verification code is: {$this->code}. It expires in 10 minutes. Do not share this code.";
}
}
---
## Verification Endpoint
Create the API endpoint for verification:
```php
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Services\PhoneVerificationService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PhoneVerificationController extends Controller
{
public function __construct(
protected PhoneVerificationService $verificationService
) {}
public function notice()
{
return view('auth.verify-phone');
}
public function verify(Request $request)
{
$request->validate([
'code' => 'required|string|size:6',
]);
$user = $request->user();
if ($user->phone_verified_at) {
return response()->json(['message' => 'Phone already verified.']);
}
$verified = $this->verificationService->verify($user, $request->code);
if (!$verified) {
throw ValidationException::withMessages([
'code' => ['Invalid or expired verification code.'],
]);
}
return response()->json([
'message' => 'Phone number verified successfully.',
]);
}
}
---
## Rate Limiting
Protect your verification endpoints from abuse:
```php
<?php
namespace App\Http\Controllers\Auth;
use App\Services\PhoneVerificationService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\RateLimiter;
class PhoneVerificationController extends Controller
{
public function send(Request $request)
{
$request->validate(['phone' => 'required|phone:mobile']);
$user = $request->user();
$key = 'phone-verification:' . $user->id;
// Max 3 sends per hour
if (RateLimiter::tooManyAttempts($key, 3)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "Too many attempts. Try again in {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
$this->verificationService->sendCode($user);
return response()->json([
'message' => 'Verification code sent.',
]);
}
public function verify(Request $request)
{
$request->validate(['code' => 'required|string|size:6']);
$user = $request->user();
$key = 'phone-verify:' . $user->id;
// Max 5 verification attempts per 30 minutes
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "Too many attempts. Try again in {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 1800);
// ... verification logic
}
}
---
## Resending Verification Codes
Resend with a cooldown period to prevent abuse:
```php
<?php
namespace App\Http\Controllers\Auth;
use App\Services\PhoneVerificationService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class PhoneVerificationController extends Controller
{
public function resend(Request $request)
{
$user = $request->user();
// Check cooldown
$lastSent = $user->verification_code_sent_at;
if ($lastSent && Carbon::parse($lastSent)->addSeconds(60)->isFuture()) {
$seconds = Carbon::parse($lastSent)->addSeconds(60)->diffInSeconds(now());
return response()->json([
'message' => "Please wait {$seconds} seconds before requesting a new code.",
], 429);
}
$this->verificationService->sendCode($user);
return response()->json([
'message' => 'New verification code sent.',
]);
}
}
---
## Integration with Registration
Hook verification into the registration flow:
```php
<?php
namespace App\Http\Controllers\Auth;
use App\Models\User;
use App\Services\PhoneVerificationService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class RegisteredUserController extends Controller
{
public function __construct(
protected PhoneVerificationService $verificationService
) {}
public function store(Request $request)
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'phone' => ['required', 'phone:mobile', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'phone' => $request->phone,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
// Send verification code
$this->verificationService->sendCode($user);
// Log the user in
auth()->login($user);
// Redirect to verification page instead of dashboard
return redirect()->route('verification.phone.notice');
}
}
---
Add a verification notice page in your routes:
```php
<?php
use App\Http\Controllers\Auth\PhoneVerificationController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
Route::get('/verify-phone', [PhoneVerificationController::class, 'notice'])
->name('verification.phone.notice');
Route::post('/verify-phone/send', [PhoneVerificationController::class, 'send'])
->name('verification.phone.send');
Route::post('/verify-phone/resend', [PhoneVerificationController::class, 'resend'])
->name('verification.phone.resend');
Route::post('/verify-phone', [PhoneVerificationController::class, 'verify'])
->name('verification.phone.verify');
});
---
## Testing Phone Verification
```php
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Notifications\PhoneVerificationCode;
use App\Services\PhoneVerificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class PhoneVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_request_verification_code()
{
Notification::fake();
$user = User::factory()->create(['phone' => '+1234567890']);
$this->actingAs($user)
->postJson('/verify-phone/send')
->assertOk();
Notification::assertSentTo($user, PhoneVerificationCode::class);
}
public function test_user_can_verify_with_valid_code()
{
$user = User::factory()->create(['phone' => '+1234567890']);
$service = app(PhoneVerificationService::class);
$verification = $service->sendCode($user);
$this->actingAs($user)
->postJson('/verify-phone', ['code' => $verification->code])
->assertOk();
$this->assertNotNull($user->fresh()->phone_verified_at);
}
public function test_invalid_code_returns_error()
{
$user = User::factory()->create(['phone' => '+1234567890']);
$service = app(PhoneVerificationService::class);
$service->sendCode($user);
$this->actingAs($user)
->postJson('/verify-phone', ['code' => '000000'])
->assertStatus(422);
}
public function test_rate_limiting_blocks_excessive_requests()
{
$user = User::factory()->create(['phone' => '+1234567890']);
$this->actingAs($user);
// Send 3 codes (hits limit)
for ($i = 0; $i < 3; $i++) {
$this->postJson('/verify-phone/send')->assertOk();
}
// 4th attempt should be blocked
$this->postJson('/verify-phone/send')->assertStatus(429);
}
}
---
## Conclusion
Phone number verification is a fundamental security feature that every Laravel app with user accounts should implement. With this guide, you've built a complete system covering code generation, sending, verification, rate limiting, and registration integration.
For SMS delivery, use our [Laravel SMS API](/services/laravel-sms-api) for reliable message delivery. Need help with custom integration? Check out our [custom Laravel SMS development](/services/custom-laravel-sms-development) services or explore our [SMS gateway integration](/services/laravel-sms-gateway-integration-service) expertise.