Laravel 12 Custom Email Verification: Build a Secure Auth System

Suresh RamaniSuresh Ramani
21 min read

Table of contents

Building a secure authentication system is crucial for any modern web application. While Laravel provides excellent built-in email verification features, creating a custom email verification system gives you complete control over the user experience, security measures, and business logic. In this comprehensive guide, you'll learn how to implement a robust custom email verification system in Laravel 12 that enhances security while providing a seamless user experience.

Why Custom Email Verification Matters for Secure Applications

Email verification serves as the first line of defense against fraudulent registrations and ensures that users provide valid email addresses. When a new user clicks on the Sign-up button of an app, they usually get a confirmation email with an activation link. This is needed to make sure that the user owns the email address entered during the sign-up.

A custom email verification system offers several advantages:

  • Enhanced Security: Implement custom token generation and validation logic

  • Better User Experience: Create branded, personalized verification emails

  • Business Logic Integration: Add custom rules and workflows specific to your application

  • Scalability: Build verification systems that can handle high-volume applications

  • Analytics: Track verification rates and user behavior for better insights

Overview of Laravel 12's Built-In Authentication System

Laravel includes built-in authentication and session services which are typically accessed via the Auth and Session facades. The framework provides a solid foundation for authentication, but understanding its limitations helps you decide when to implement custom solutions.

Understanding Laravel's Default Email Verification

Laravel's default email verification system works through the MustVerifyEmail interface and includes:

  • Built-in VerifyEmail notification

  • Signed URL generation for security

  • Automatic user verification status management

  • Three routes that need to be defined: a notice route, a verification route, and a resend route

Pros and Cons of Using the Default Setup

Pros:

  • Quick implementation with minimal code

  • Built-in security features like signed URLs

  • Automatic integration with Laravel's authentication system

  • Well-tested and maintained by the Laravel team

Cons:

  • Limited customization options

  • Generic email templates

  • Inflexible token management

  • Difficult to integrate with complex business logic

Planning a Custom Email Verification System

Before diving into implementation, it's essential to plan your custom verification system carefully. This ensures you build a scalable, secure solution that meets your specific requirements.

Key Features of a Custom Verification Flow

A well-designed custom email verification system should include:

  1. Custom Token Generation: Create unique, time-limited verification tokens

  2. Secure Token Storage: Store tokens safely with proper database indexing

  3. Branded Email Templates: Design verification emails that match your brand

  4. Rate Limiting: Prevent abuse by limiting verification email requests

  5. Comprehensive Logging: Track verification attempts for security monitoring

  6. Flexible Expiration: Configure token expiry based on security requirements

Security Considerations Before Implementation

Security should be at the forefront of your custom verification system:

  • Token Entropy: Use cryptographically secure random token generation

  • Database Security: Implement proper indexing and encryption for token storage

  • Rate Limiting: Protect against brute force attacks and spam

  • HTTPS Enforcement: Ensure all verification links use secure connections

  • Input Validation: Validate all user inputs rigorously

Setting Up Laravel 12 Authentication

Start by setting up the foundation for your custom email verification system.

Installing Laravel Breeze, Jetstream, or UI

For this tutorial, we'll use Laravel Breeze as it provides a clean, minimal authentication setup:

composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run dev
php artisan migrate

Creating Authentication Scaffolding for Email Verification

Modify your User model to include email verification capabilities:

<?php

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable implements MustVerifyEmail
{
    use HasApiTokens, HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
        'email_verified_at',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];

    public function isEmailVerified(): bool
    {
        return !is_null($this->email_verified_at);
    }
}

Creating the Email Verification Logic

Now, let's build the core verification logic with a custom controller and routes.

Generating the Custom Verification Controller

Create a dedicated controller for handling email verification:

php artisan make:controller Auth/CustomEmailVerificationController
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\EmailVerificationToken;
use App\Mail\CustomVerificationEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Carbon\Carbon;

class CustomEmailVerificationController extends Controller
{
    public function show(Request $request)
    {
        return $request->user()->hasVerifiedEmail()
            ? redirect()->intended('/dashboard')
            : view('auth.verify-email');
    }

    public function verify(Request $request, $token)
    {
        $verificationToken = EmailVerificationToken::where('token', $token)
            ->where('expires_at', '>', Carbon::now())
            ->first();

        if (!$verificationToken) {
            return redirect()->route('verification.notice')
                ->with('error', 'Invalid or expired verification link.');
        }

        $user = User::find($verificationToken->user_id);

        if (!$user) {
            return redirect()->route('verification.notice')
                ->with('error', 'User not found.');
        }

        if ($user->hasVerifiedEmail()) {
            return redirect()->route('dashboard')
                ->with('success', 'Email already verified.');
        }

        $user->markEmailAsVerified();
        $verificationToken->delete();

        return redirect()->route('dashboard')
            ->with('success', 'Email successfully verified!');
    }

    public function resend(Request $request)
    {
        $request->validate([
            'email' => 'required|email|exists:users,email',
        ]);

        $user = User::where('email', $request->email)->first();

        if ($user->hasVerifiedEmail()) {
            return back()->with('info', 'Email is already verified.');
        }

        $this->sendVerificationEmail($user);

        return back()->with('success', 'Verification email sent!');
    }

    private function sendVerificationEmail(User $user)
    {
        // Delete any existing tokens for this user
        EmailVerificationToken::where('user_id', $user->id)->delete();

        // Create new verification token
        $token = Str::random(64);

        EmailVerificationToken::create([
            'user_id' => $user->id,
            'token' => $token,
            'expires_at' => Carbon::now()->addHours(24),
        ]);

        // Send verification email
        Mail::to($user->email)->send(new CustomVerificationEmail($user, $token));
    }
}

Defining Verification Routes with Middleware

Add the verification routes to your routes/web.php file:

use App\Http\Controllers\Auth\CustomEmailVerificationController;

Route::middleware('auth')->group(function () {
    Route::get('/email/verify', [CustomEmailVerificationController::class, 'show'])
        ->name('verification.notice');

    Route::get('/email/verify/{token}', [CustomEmailVerificationController::class, 'verify'])
        ->name('verification.verify');

    Route::post('/email/verification-notification', [CustomEmailVerificationController::class, 'resend'])
        ->middleware('throttle:6,1')
        ->name('verification.send');
});

Designing the Verification Token System

A robust token system is crucial for secure email verification.

Creating Custom Tokens with Expiry Logic

First, create a migration for storing verification tokens:

php artisan make:migration create_email_verification_tokens_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()
    {
        Schema::create('email_verification_tokens', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('token', 64)->unique();
            $table->timestamp('expires_at');
            $table->timestamps();

            $table->index(['user_id', 'token']);
            $table->index('expires_at');
        });
    }

    public function down()
    {
        Schema::dropIfExists('email_verification_tokens');
    }
};

Storing and Managing Tokens Securely in the Database

Create the EmailVerificationToken model:

php artisan make:model EmailVerificationToken
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;

class EmailVerificationToken extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'token',
        'expires_at',
    ];

    protected $casts = [
        'expires_at' => 'datetime',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function isExpired(): bool
    {
        return $this->expires_at < Carbon::now();
    }

    public function scopeValid($query)
    {
        return $query->where('expires_at', '>', Carbon::now());
    }
}

Sending the Custom Verification Email

Create a professional, branded verification email that enhances user experience.

Creating a Mailable Class for the Verification Email

Generate a custom mailable class:

php artisan make:mail CustomVerificationEmail
<?php

namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class CustomVerificationEmail extends Mailable
{
    use Queueable, SerializesModels;

    public User $user;
    public string $token;

    public function __construct(User $user, string $token)
    {
        $this->user = $user;
        $this->token = $token;
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Verify Your Email Address - ' . config('app.name'),
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.verify-email',
            with: [
                'user' => $this->user,
                'verificationUrl' => route('verification.verify', ['token' => $this->token]),
            ],
        );
    }
}

Customizing Email Templates with Branding and Call-to-Action

Create a professional email template at resources/views/emails/verify-email.blade.php:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Verify Your Email</title>
    <style>
        body {
            font-family: 'Arial', sans-serif;
            line-height: 1.6;
            color: #333;
            max-width: 600px;
            margin: 0 auto;
            padding: 20px;
        }
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px;
            text-align: center;
            border-radius: 10px 10px 0 0;
        }
        .content {
            background: #f8f9fa;
            padding: 30px;
            border-radius: 0 0 10px 10px;
        }
        .button {
            display: inline-block;
            background: #667eea;
            color: white !important;
            padding: 15px 30px;
            text-decoration: none;
            border-radius: 5px;
            margin: 20px 0;
            font-weight: bold;
        }
        .footer {
            text-align: center;
            margin-top: 30px;
            font-size: 14px;
            color: #666;
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>Welcome to {{ config('app.name') }}</h1>
        <p>Please verify your email address</p>
    </div>

    <div class="content">
        <h2>Hello {{ $user->name }}!</h2>

        <p>Thank you for creating an account with {{ config('app.name') }}. To complete your registration and start using your account, please verify your email address by clicking the button below:</p>

        <div style="text-align: center;">
            <a href="{{ $verificationUrl }}" class="button">Verify Email Address</a>
        </div>

        <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
        <p style="word-break: break-all; color: #667eea;">{{ $verificationUrl }}</p>

        <p><strong>Important:</strong> This verification link will expire in 24 hours for security reasons.</p>

        <p>If you didn't create an account with {{ config('app.name') }}, please ignore this email.</p>
    </div>

    <div class="footer">
        <p>&copy; {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>
    </div>
</body>
</html>

Implement secure verification link handling with proper validation.

Building the Route to Handle Token-Based Verification

The verification logic in your controller should include comprehensive security checks:

public function verify(Request $request, $token)
{
    // Validate token format
    if (!$token || strlen($token) !== 64) {
        return redirect()->route('verification.notice')
            ->with('error', 'Invalid verification link format.');
    }

    // Find valid token
    $verificationToken = EmailVerificationToken::where('token', $token)
        ->valid()
        ->first();

    if (!$verificationToken) {
        return redirect()->route('verification.notice')
            ->with('error', 'Invalid or expired verification link.');
    }

    $user = $verificationToken->user;

    if ($user->hasVerifiedEmail()) {
        $verificationToken->delete();
        return redirect()->route('dashboard')
            ->with('info', 'Email already verified.');
    }

    // Mark as verified and clean up
    $user->markEmailAsVerified();
    $verificationToken->delete();

    // Log successful verification
    \Log::info('Email verified successfully', [
        'user_id' => $user->id,
        'email' => $user->email,
        'ip' => $request->ip(),
    ]);

    return redirect()->route('dashboard')
        ->with('success', 'Email successfully verified! Welcome to ' . config('app.name'));
}

Validating and Marking the User as Verified

Add a helper method to your User model for marking verification:

public function markEmailAsVerified()
{
    $this->email_verified_at = now();
    $this->save();

    // Clean up any remaining tokens
    EmailVerificationToken::where('user_id', $this->id)->delete();
}

Adding Throttling and Security Measures

Implement comprehensive security measures to protect your verification system.

Limiting Verification Email Requests to Prevent Abuse

Add rate limiting to your verification routes:

Route::post('/email/verification-notification', [CustomEmailVerificationController::class, 'resend'])
    ->middleware(['throttle:3,1', 'signed'])
    ->name('verification.send');

Create a custom middleware for additional verification security:

php artisan make:middleware VerificationSecurityMiddleware
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

class VerificationSecurityMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $key = 'verification:' . $request->ip();

        if (RateLimiter::tooManyAttempts($key, 5)) {
            $seconds = RateLimiter::availableIn($key);
            return response()->json([
                'message' => "Too many verification attempts. Try again in {$seconds} seconds."
            ], 429);
        }

        RateLimiter::hit($key, 300); // 5 minutes

        return $next($request);
    }
}

Protecting Routes with Signed URLs and Middleware

Enhance security by implementing signed URLs for verification links:

private function sendVerificationEmail(User $user)
{
    EmailVerificationToken::where('user_id', $user->id)->delete();

    $token = Str::random(64);

    EmailVerificationToken::create([
        'user_id' => $user->id,
        'token' => hash('sha256', $token), // Hash the token for storage
        'expires_at' => Carbon::now()->addHours(24),
    ]);

    // Create signed URL for additional security
    $verificationUrl = URL::temporarySignedRoute(
        'verification.verify',
        Carbon::now()->addHours(24),
        ['token' => $token]
    );

    Mail::to($user->email)->send(new CustomVerificationEmail($user, $verificationUrl));
}

Building the Resend Verification Feature

Create a user-friendly resend feature with proper security measures.

Creating a Route and Controller for Resending Emails

Enhance the resend functionality with better UX:

public function resend(Request $request)
{
    $request->validate([
        'email' => 'required|email|exists:users,email',
    ]);

    $user = User::where('email', $request->email)->first();

    if ($user->hasVerifiedEmail()) {
        return response()->json([
            'message' => 'Email is already verified.',
            'status' => 'already_verified'
        ]);
    }

    // Check if user has requested too many emails recently
    $recentTokens = EmailVerificationToken::where('user_id', $user->id)
        ->where('created_at', '>', Carbon::now()->subMinutes(5))
        ->count();

    if ($recentTokens >= 3) {
        return response()->json([
            'message' => 'Please wait before requesting another verification email.',
            'status' => 'rate_limited'
        ], 429);
    }

    $this->sendVerificationEmail($user);

    return response()->json([
        'message' => 'Verification email sent successfully!',
        'status' => 'sent'
    ]);
}

UX Considerations and Feedback Messages

Create a responsive verification notice page at resources/views/auth/verify-email.blade.php:

<x-guest-layout>
    <div class="mb-4 text-sm text-gray-600">
        {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
    </div>

    @if (session('status') == 'verification-link-sent')
        <div class="mb-4 font-medium text-sm text-green-600">
            {{ __('A new verification link has been sent to the email address you provided during registration.') }}
        </div>
    @endif

    <div class="mt-4 flex items-center justify-between">
        <form method="POST" action="{{ route('verification.send') }}" id="resend-form">
            @csrf
            <input type="hidden" name="email" value="{{ auth()->user()->email }}">
            <button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900" id="resend-btn">
                {{ __('Resend Verification Email') }}
            </button>
        </form>

        <form method="POST" action="{{ route('logout') }}">
            @csrf
            <button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 ml-2">
                {{ __('Log Out') }}
            </button>
        </form>
    </div>

    <script>
        document.getElementById('resend-form').addEventListener('submit', function(e) {
            e.preventDefault();

            const btn = document.getElementById('resend-btn');
            const originalText = btn.textContent;

            btn.textContent = 'Sending...';
            btn.disabled = true;

            fetch(this.action, {
                method: 'POST',
                body: new FormData(this),
                headers: {
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
                }
            })
            .then(response => response.json())
            .then(data => {
                if (data.status === 'sent') {
                    btn.textContent = 'Email Sent!';
                    setTimeout(() => {
                        btn.textContent = originalText;
                        btn.disabled = false;
                    }, 5000);
                } else {
                    btn.textContent = originalText;
                    btn.disabled = false;
                    alert(data.message);
                }
            })
            .catch(error => {
                btn.textContent = originalText;
                btn.disabled = false;
                alert('An error occurred. Please try again.');
            });
        });
    </script>
</x-guest-layout>

Customizing the Verification Notice Page

Create an engaging and informative verification notice page.

Designing a User-Friendly Pending Verification Screen

Enhance the user experience with a modern, informative verification page:

<x-guest-layout>
    <div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
        <div class="max-w-md w-full space-y-8">
            <div class="text-center">
                <div class="mx-auto h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
                    <svg class="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
                    </svg>
                </div>
                <h2 class="mt-6 text-3xl font-extrabold text-gray-900">
                    Check your email
                </h2>
                <p class="mt-2 text-sm text-gray-600">
                    We've sent a verification link to <strong>{{ auth()->user()->email }}</strong>
                </p>
            </div>

            <div class="bg-white p-6 rounded-lg shadow-md">
                <div class="text-sm text-gray-600 mb-4">
                    <p class="mb-2">To complete your registration:</p>
                    <ol class="list-decimal list-inside space-y-1 text-xs">
                        <li>Check your email inbox (and spam folder)</li>
                        <li>Click the verification link in the email</li>
                        <li>Return here to access your account</li>
                    </ol>
                </div>

                @if (session('success'))
                    <div class="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
                        {{ session('success') }}
                    </div>
                @endif

                @if (session('error'))
                    <div class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
                        {{ session('error') }}
                    </div>
                @endif

                <div class="space-y-3">
                    <button id="resend-btn" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition duration-200">
                        Resend verification email
                    </button>

                    <form method="POST" action="{{ route('logout') }}">
                        @csrf
                        <button type="submit" class="w-full bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300 transition duration-200">
                            Sign out
                        </button>
                    </form>
                </div>
            </div>

            <div class="text-center">
                <p class="text-xs text-gray-500">
                    Having trouble? <a href="mailto:support@yourapp.com" class="text-blue-600 hover:underline">Contact support</a>
                </p>
            </div>
        </div>
    </div>
</x-guest-layout>

Adding Conditional Redirects Based on Verification Status

Create middleware to handle verification status redirects:

php artisan make:middleware EnsureEmailIsVerified
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL;

class EnsureEmailIsVerified
{
    public function handle(Request $request, Closure $next, $redirectToRoute = null)
    {
        if (! $request->user() ||
            ($request->user() instanceof MustVerifyEmail &&
            ! $request->user()->hasVerifiedEmail())) {
            return $request->expectsJson()
                    ? abort(403, 'Your email address is not verified.')
                    : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));
        }

        return $next($request);
    }
}

Testing the Entire Flow

Comprehensive testing ensures your verification system works flawlessly.

Writing Unit and Feature Tests for Verification Logic

Create comprehensive tests for your verification system:

php artisan make:test EmailVerificationTest
<?php

namespace Tests\Feature;

use App\Models\User;
use App\Models\EmailVerificationToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
use Carbon\Carbon;

class EmailVerificationTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_verify_email_with_valid_token()
    {
        $user = User::factory()->create(['email_verified_at' => null]);
        $token = EmailVerificationToken::create([
            'user_id' => $user->id,
            'token' => 'valid-token-123',
            'expires_at' => Carbon::now()->addHours(24),
        ]);

        $response = $this->get(route('verification.verify', ['token' => 'valid-token-123']));

        $response->assertRedirect(route('dashboard'));
        $this->assertTrue($user->fresh()->hasVerifiedEmail());
        $this->assertDatabaseMissing('email_verification_tokens', ['id' => $token->id]);
    }

    public function test_user_cannot_verify_email_with_expired_token()
    {
        $user = User::factory()->create(['email_verified_at' => null]);
        EmailVerificationToken::create([
            'user_id' => $user->id,
            'token' => 'expired-token-123',
            'expires_at' => Carbon::now()->subHours(1),
        ]);

        $response = $this->get(route('verification.verify', ['token' => 'expired-token-123']));

        $response->assertRedirect(route('verification.notice'));
        $this->assertFalse($user->fresh()->hasVerifiedEmail());
    }

    public function test_user_can_resend_verification_email()
    {
        Mail::fake();
        $user = User::factory()->create(['email_verified_at' => null]);
        $this->actingAs($user);

        $response = $this->post(route('verification.send'), ['email' => $user->email]);

        $response->assertStatus(200);
        Mail::assertSent(\App\Mail\CustomVerificationEmail::class);
    }

    public function test_verification_email_is_rate_limited()
    {
        $user = User::factory()->create(['email_verified_at' => null]);
        $this->actingAs($user);

        // Create multiple recent tokens to trigger rate limiting
        for ($i = 0; $i < 3; $i++) {
            EmailVerificationToken::create([
                'user_id' => $user->id,
                'token' => 'token-' . $i,
                'expires_at' => Carbon::now()->addHours(24),
                'created_at' => Carbon::now()->subMinutes(1),
            ]);
        }

        $response = $this->post(route('verification.send'), ['email' => $user->email]);

        $response->assertStatus(429);
    }
}

Simulating Expired and Invalid Token Scenarios

Add tests for edge cases and security scenarios:

public function test_invalid_token_format_returns_error()
{
    $response = $this->get(route('verification.verify', ['token' => 'invalid']));
    $response->assertRedirect(route('verification.notice'));
    $response->assertSessionHas('error');
}

public function test_already_verified_user_redirects_to_dashboard()
{
    $user = User::factory()->create(['email_verified_at' => now()]);
    $token = EmailVerificationToken::create([
        'user_id' => $user->id,
        'token' => 'valid-token-123',
        'expires_at' => Carbon::now()->addHours(24),
    ]);

    $response = $this->get(route('verification.verify', ['token' => 'valid-token-123']));

    $response->assertRedirect(route('dashboard'));
    $response->assertSessionHas('info', 'Email already verified.');
}

public function test_nonexistent_user_token_returns_error()
{
    EmailVerificationToken::create([
        'user_id' => 999, // Non-existent user ID
        'token' => 'orphaned-token',
        'expires_at' => Carbon::now()->addHours(24),
    ]);

    $response = $this->get(route('verification.verify', ['token' => 'orphaned-token']));
    $response->assertRedirect(route('verification.notice'));
    $response->assertSessionHas('error');
}

Integrating with Frontend Frameworks

Modern applications often use JavaScript frameworks for enhanced user experience.

Handling Email Verification in Inertia.js or Vue SPA

For Inertia.js applications, create a Vue component for email verification:

<template>
    <div class="min-h-screen flex items-center justify-center bg-gray-50">
        <div class="max-w-md w-full bg-white rounded-lg shadow-md p-6">
            <div class="text-center mb-6">
                <div class="mx-auto h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center mb-4">
                    <svg class="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
                    </svg>
                </div>
                <h2 class="text-2xl font-bold text-gray-900">Verify Your Email</h2>
                <p class="text-gray-600 mt-2">
                    We've sent a verification link to <strong>{{ $page.props.auth.user.email }}</strong>
                </p>
            </div>

            <div v-if="message" :class="messageClass" class="p-3 rounded mb-4">
                {{ message }}
            </div>

            <div class="space-y-3">
                <button 
                    @click="resendEmail" 
                    :disabled="isResending || cooldownTime > 0"
                    class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition duration-200"
                >
                    <template v-if="isResending">
                        <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                        </svg>
                        Sending...
                    </template>
                    <template v-else-if="cooldownTime > 0">
                        Resend in {{ cooldownTime }}s
                    </template>
                    <template v-else>
                        Resend Verification Email
                    </template>
                </button>

                <Link 
                    :href="route('logout')" 
                    method="post" 
                    class="w-full bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300 transition duration-200 text-center block"
                >
                    Sign Out
                </Link>
            </div>
        </div>
    </div>
</template>

<script>
import { Link } from '@inertiajs/vue3'

export default {
    components: {
        Link
    },
    data() {
        return {
            isResending: false,
            message: '',
            messageType: 'success',
            cooldownTime: 0,
            cooldownInterval: null
        }
    },
    computed: {
        messageClass() {
            return this.messageType === 'success' 
                ? 'bg-green-100 border border-green-400 text-green-700'
                : 'bg-red-100 border border-red-400 text-red-700'
        }
    },
    methods: {
        async resendEmail() {
            if (this.isResending || this.cooldownTime > 0) return;

            this.isResending = true;
            this.message = '';

            try {
                const response = await fetch(route('verification.send'), {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
                    },
                    body: JSON.stringify({
                        email: this.$page.props.auth.user.email
                    })
                });

                const data = await response.json();

                if (response.ok) {
                    this.message = data.message;
                    this.messageType = 'success';
                    this.startCooldown(60); // 60 second cooldown
                } else {
                    this.message = data.message || 'An error occurred';
                    this.messageType = 'error';
                }
            } catch (error) {
                this.message = 'Network error. Please try again.';
                this.messageType = 'error';
            } finally {
                this.isResending = false;
            }
        },
        startCooldown(seconds) {
            this.cooldownTime = seconds;
            this.cooldownInterval = setInterval(() => {
                this.cooldownTime--;
                if (this.cooldownTime <= 0) {
                    clearInterval(this.cooldownInterval);
                }
            }, 1000);
        }
    },
    beforeUnmount() {
        if (this.cooldownInterval) {
            clearInterval(this.cooldownInterval);
        }
    }
}
</script>

Displaying Realtime Feedback to Users After Verification

Create an API endpoint for checking verification status:

// Add to your CustomEmailVerificationController
public function checkStatus(Request $request)
{
    $user = auth()->user();

    return response()->json([
        'verified' => $user->hasVerifiedEmail(),
        'email' => $user->email,
        'verified_at' => $user->email_verified_at?->toISOString(),
    ]);
}

Add the route:

Route::get('/email/verification-status', [CustomEmailVerificationController::class, 'checkStatus'])
    ->middleware('auth')
    ->name('verification.status');

Create a real-time verification checker component:

<template>
    <div v-if="!verified" class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
        <div class="flex">
            <div class="flex-shrink-0">
                <svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
                </svg>
            </div>
            <div class="ml-3">
                <p class="text-sm text-yellow-700">
                    Your email address is not verified. 
                    <Link :href="route('verification.notice')" class="font-medium underline">
                        Click here to verify your email
                    </Link>
                </p>
            </div>
        </div>
    </div>
</template>

<script>
import { Link } from '@inertiajs/vue3'

export default {
    components: {
        Link
    },
    data() {
        return {
            verified: this.$page.props.auth.user.email_verified_at !== null,
            checkInterval: null
        }
    },
    mounted() {
        if (!this.verified) {
            this.startVerificationCheck();
        }
    },
    methods: {
        async checkVerificationStatus() {
            try {
                const response = await fetch(route('verification.status'));
                const data = await response.json();

                if (data.verified && !this.verified) {
                    this.verified = true;
                    this.stopVerificationCheck();

                    // Show success message
                    this.$toast.success('Email verified successfully!');

                    // Optionally reload the page to update user state
                    setTimeout(() => {
                        window.location.reload();
                    }, 2000);
                }
            } catch (error) {
                console.error('Error checking verification status:', error);
            }
        },
        startVerificationCheck() {
            this.checkInterval = setInterval(() => {
                this.checkVerificationStatus();
            }, 5000); // Check every 5 seconds
        },
        stopVerificationCheck() {
            if (this.checkInterval) {
                clearInterval(this.checkInterval);
                this.checkInterval = null;
            }
        }
    },
    beforeUnmount() {
        this.stopVerificationCheck();
    }
}
</script>

Common Issues and How to Fix Them

Understanding common problems helps you build a more robust system.

Debugging Token Expiry or Signature Mismatch Errors

Create a comprehensive debugging system for verification issues:

// Add to your CustomEmailVerificationController
public function debug(Request $request, $token)
{
    if (!app()->environment('local')) {
        abort(404);
    }

    $debugInfo = [
        'token' => $token,
        'token_length' => strlen($token),
        'token_exists' => EmailVerificationToken::where('token', $token)->exists(),
        'token_valid' => EmailVerificationToken::where('token', $token)
            ->where('expires_at', '>', Carbon::now())
            ->exists(),
        'current_time' => Carbon::now()->toISOString(),
    ];

    $verificationToken = EmailVerificationToken::where('token', $token)->first();
    if ($verificationToken) {
        $debugInfo['token_info'] = [
            'user_id' => $verificationToken->user_id,
            'expires_at' => $verificationToken->expires_at->toISOString(),
            'is_expired' => $verificationToken->isExpired(),
            'created_at' => $verificationToken->created_at->toISOString(),
        ];
    }

    return response()->json($debugInfo);
}

Add error logging for verification attempts:

public function verify(Request $request, $token)
{
    try {
        // Existing verification logic...
    } catch (\Exception $e) {
        \Log::error('Email verification failed', [
            'token' => $token,
            'error' => $e->getMessage(),
            'user_id' => $request->user()?->id,
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
        ]);

        return redirect()->route('verification.notice')
            ->with('error', 'Verification failed. Please try again or contact support.');
    }
}

Dealing with Unverified Users Trying to Access Protected Pages

Create a comprehensive middleware system for handling unverified users:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Contracts\Auth\MustVerifyEmail;

class HandleUnverifiedUsers
{
    public function handle(Request $request, Closure $next)
    {
        $user = $request->user();

        if (!$user) {
            return redirect()->route('login');
        }

        if ($user instanceof MustVerifyEmail && !$user->hasVerifiedEmail()) {
            if ($request->expectsJson()) {
                return response()->json([
                    'message' => 'Email verification required.',
                    'verification_url' => route('verification.notice')
                ], 403);
            }

            return redirect()->route('verification.notice')
                ->with('info', 'Please verify your email address to continue.');
        }

        return $next($request);
    }
}

Register the middleware in your app/Http/Kernel.php:

protected $routeMiddleware = [
    // ... other middleware
    'verified.custom' => \App\Http\Middleware\HandleUnverifiedUsers::class,
];

Apply to protected routes:

Route::middleware(['auth', 'verified.custom'])->group(function () {
    Route::get('/dashboard', function () {
        return view('dashboard');
    })->name('dashboard');

    Route::get('/profile', [ProfileController::class, 'show'])->name('profile.show');
    // ... other protected routes
});

Best Practices for Email Verification in Laravel

Implement industry best practices for maximum security and user experience.

Keeping Logic Modular and Scalable

Create a dedicated service class for verification logic:

php artisan make:service EmailVerificationService
<?php

namespace App\Services;

use App\Models\User;
use App\Models\EmailVerificationToken;
use App\Mail\CustomVerificationEmail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Carbon\Carbon;

class EmailVerificationService
{
    public function sendVerificationEmail(User $user): bool
    {
        try {
            $this->cleanupExpiredTokens();
            $this->deleteExistingTokens($user);

            $token = $this->generateToken();
            $this->storeToken($user, $token);

            Mail::to($user->email)->send(new CustomVerificationEmail($user, $token));

            $this->logVerificationEmailSent($user);
            return true;
        } catch (\Exception $e) {
            \Log::error('Failed to send verification email', [
                'user_id' => $user->id,
                'error' => $e->getMessage()
            ]);
            return false;
        }
    }

    public function verifyToken(string $token): array
    {
        $verificationToken = EmailVerificationToken::where('token', hash('sha256', $token))
            ->where('expires_at', '>', Carbon::now())
            ->first();

        if (!$verificationToken) {
            return ['success' => false, 'message' => 'Invalid or expired token'];
        }

        $user = $verificationToken->user;

        if ($user->hasVerifiedEmail()) {
            $verificationToken->delete();
            return ['success' => true, 'message' => 'Email already verified', 'user' => $user];
        }

        $user->markEmailAsVerified();
        $verificationToken->delete();

        $this->logVerificationSuccess($user);

        return ['success' => true, 'message' => 'Email verified successfully', 'user' => $user];
    }

    private function generateToken(): string
    {
        return Str::random(64);
    }

    private function storeToken(User $user, string $token): void
    {
        EmailVerificationToken::create([
            'user_id' => $user->id,
            'token' => hash('sha256', $token),
            'expires_at' => Carbon::now()->addHours(config('auth.verification.expire', 24)),
        ]);
    }

    private function deleteExistingTokens(User $user): void
    {
        EmailVerificationToken::where('user_id', $user->id)->delete();
    }

    private function cleanupExpiredTokens(): void
    {
        EmailVerificationToken::where('expires_at', '<', Carbon::now())->delete();
    }

    private function logVerificationEmailSent(User $user): void
    {
        \Log::info('Verification email sent', [
            'user_id' => $user->id,
            'email' => $user->email,
            'timestamp' => Carbon::now()->toISOString(),
        ]);
    }

    private function logVerificationSuccess(User $user): void
    {
        \Log::info('Email verification successful', [
            'user_id' => $user->id,
            'email' => $user->email,
            'timestamp' => Carbon::now()->toISOString(),
        ]);
    }
}

Monitoring Delivery and Open Rates with Email Logs

Create a comprehensive email tracking system:

php artisan make:migration create_email_logs_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()
    {
        Schema::create('email_logs', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('email_type')->index();
            $table->string('recipient_email');
            $table->timestamp('sent_at');
            $table->timestamp('delivered_at')->nullable();
            $table->timestamp('opened_at')->nullable();
            $table->timestamp('clicked_at')->nullable();
            $table->json('metadata')->nullable();
            $table->timestamps();

            $table->index(['email_type', 'sent_at']);
            $table->index(['user_id', 'email_type']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('email_logs');
    }
};

Create the EmailLog model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class EmailLog extends Model
{
    protected $fillable = [
        'user_id',
        'email_type',
        'recipient_email',
        'sent_at',
        'delivered_at',
        'opened_at',
        'clicked_at',
        'metadata',
    ];

    protected $casts = [
        'sent_at' => 'datetime',
        'delivered_at' => 'datetime',
        'opened_at' => 'datetime',
        'clicked_at' => 'datetime',
        'metadata' => 'array',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function scopeVerificationEmails($query)
    {
        return $query->where('email_type', 'verification');
    }

    public function scopeOpened($query)
    {
        return $query->whereNotNull('opened_at');
    }

    public function scopeClicked($query)
    {
        return $query->whereNotNull('clicked_at');
    }
}

Add tracking to your verification email:

// In your CustomVerificationEmail mailable
public function build()
{
    // Log email sending
    EmailLog::create([
        'user_id' => $this->user->id,
        'email_type' => 'verification',
        'recipient_email' => $this->user->email,
        'sent_at' => now(),
        'metadata' => [
            'token' => substr($this->token, 0, 8) . '...',
            'ip' => request()->ip(),
        ],
    ]);

    return $this->view('emails.verify-email')
                ->with([
                    'user' => $this->user,
                    'verificationUrl' => $this->generateTrackableUrl(),
                ]);
}

private function generateTrackableUrl(): string
{
    $baseUrl = route('verification.verify', ['token' => $this->token]);
    $trackingParams = http_build_query([
        'utm_source' => 'email',
        'utm_medium' => 'verification',
        'utm_campaign' => 'user_verification'
    ]);

    return $baseUrl . '?' . $trackingParams;
}

Create a dashboard for monitoring verification metrics:

// Add to a dashboard controller
public function verificationMetrics()
{
    $metrics = [
        'total_sent' => EmailLog::verificationEmails()->count(),
        'total_opened' => EmailLog::verificationEmails()->opened()->count(),
        'total_clicked' => EmailLog::verificationEmails()->clicked()->count(),
        'open_rate' => $this->calculateOpenRate(),
        'click_rate' => $this->calculateClickRate(),
        'daily_stats' => $this->getDailyStats(),
    ];

    return view('admin.verification-metrics', compact('metrics'));
}

private function calculateOpenRate(): float
{
    $total = EmailLog::verificationEmails()->count();
    $opened = EmailLog::verificationEmails()->opened()->count();

    return $total > 0 ? round(($opened / $total) * 100, 2) : 0;
}

private function calculateClickRate(): float
{
    $total = EmailLog::verificationEmails()->count();
    $clicked = EmailLog::verificationEmails()->clicked()->count();

    return $total > 0 ? round(($clicked / $total) * 100, 2) : 0;
}

Conclusion

Building a custom email verification system in Laravel 12 provides complete control over your authentication flow while maintaining security best practices. By implementing the techniques covered in this guide, you've created a robust, scalable verification system that enhances user experience and provides comprehensive security measures.

Final Thoughts on Building Secure, Custom Verification Flows

The custom verification system you've built offers several advantages over Laravel's default implementation:

  • Enhanced Security: Custom token generation, rate limiting, and comprehensive logging

  • Better User Experience: Branded emails, real-time feedback, and intuitive interfaces

  • Business Logic Integration: Flexible workflows that adapt to your specific requirements

  • Scalability: Modular architecture that can handle high-volume applications

  • Analytics: Detailed tracking and monitoring of verification processes

Remember to regularly review and update your verification system to address new security threats and improve user experience based on feedback and analytics data.

Next Steps: Passwordless Login and Multi-Factor Authentication

Consider extending your authentication system with these advanced features:

  1. Passwordless Authentication: Allow users to log in using email verification links

  2. Multi-Factor Authentication: Add SMS or authenticator app verification

  3. Social Login Integration: Combine email verification with OAuth providers

  4. Progressive Registration: Collect user information gradually after email verification

  5. Account Recovery: Implement secure account recovery using verified emails

By following the patterns and practices outlined in this guide, you'll be well-equipped to implement these advanced authentication features while maintaining security and usability standards.

The foundation you've built with custom email verification serves as a solid base for any authentication enhancement you choose to implement in the future.​

0
Subscribe to my newsletter

Read articles from Suresh Ramani directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Suresh Ramani
Suresh Ramani