emailverifier.dev
Posts

Laravel Signup Email Validation Tutorial

| 4 min read | Usama Ejaz
Laravel validation rule cover with an email inside a shield and a check mark.

This tutorial builds a working Laravel signup-email form backed by a custom validation rule. You will create a fresh application, add the server-only project key, reject blocking email results before account creation, run the form in a browser, and prove the policy with Http::fake.

Create the Laravel application

You need PHP, Composer, and a current Laravel-compatible runtime:

composer create-project laravel/laravel laravel-email-check
cd laravel-email-check
php artisan make:rule VerifiedSignupEmail
php artisan make:controller SignupEmailController

The tutorial adds or changes:

laravel-email-check/
├─ app/Http/Controllers/SignupEmailController.php
├─ app/Rules/VerifiedSignupEmail.php
├─ config/services.php
├─ resources/views/signup.blade.php
└─ routes/web.php

Configure the service key

Create an emailverifier.dev project and copy its API key. Add it to .env:

EMAILVERIFIER_API_KEY=replace_with_your_project_key

Add this entry inside the array returned by config/services.php:

'emailverifier' => [
    'key' => env('EMAILVERIFIER_API_KEY'),
],

Laravel configuration reads the environment value on the server. Do not expose it through Vite or send it as form data.

Implement the validation rule

Replace app/Rules/VerifiedSignupEmail.php with:

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class VerifiedSignupEmail implements ValidationRule
{
    public function validate(
        string $attribute,
        mixed $value,
        Closure $fail
    ): void {
        $apiKey = config('services.emailverifier.key');

        if (! is_string($apiKey) || $apiKey === '') {
            $fail('Email verification is not configured.');

            return;
        }

        try {
            $result = Http::connectTimeout(2)
                ->timeout(5)
                ->acceptJson()
                ->withHeaders([
                    'X-API-Key' => $apiKey,
                ])
                ->post(
                    'https://emailverifier.dev/api/v1/verify',
                    ['email' => $value]
                )
                ->throw()
                ->json();
        } catch (ConnectionException|RequestException) {
            $fail(
                'Email verification is temporarily unavailable.'
            );

            return;
        }

        if (($result['action'] ?? null) !== 'block') {
            return;
        }

        $signals = $result['signals'] ?? [];

        if (in_array(
            'disposable_address',
            $signals,
            true
        )) {
            $fail('Use a permanent email address.');

            return;
        }

        $suggestion = $result['suggestion'] ?? null;

        $fail(
            is_string($suggestion) && $suggestion !== ''
                ? 'Check the email address. Suggested address: '
                    .$suggestion
                : 'Check the email address and try again.'
        );
    }
}

The rule has two bounded timeouts and one external request. It fails validation on confirmed block decisions and temporary service failures. Laravel's HTTP client documentation defines connectTimeout, timeout, and throw.

Add the controller

Replace app/Http/Controllers/SignupEmailController.php with:

<?php

namespace App\Http\Controllers;

use App\Rules\VerifiedSignupEmail;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class SignupEmailController extends Controller
{
    public function create(): View
    {
        return view('signup');
    }

    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'email' => [
                'bail',
                'required',
                'email:rfc',
                'max:254',
                new VerifiedSignupEmail,
            ],
        ]);

        return response()->json([
            'accepted' => true,
            'email' => $validated['email'],
        ]);
    }
}

bail stops field validation after the first error. An empty or malformed address therefore never reaches the paid API request.

Create the form and routes

Add resources/views/signup.blade.php:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport"
      content="width=device-width, initial-scale=1">
    <title>Signup email check</title>
  </head>
  <body>
    <main>
      <h1>Check a signup email</h1>

      <form method="POST" action="/signup/check-email">
        @csrf

        <label for="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value="{{ old('email') }}"
          required
        >

        @error('email')
          <p role="alert">{{ $message }}</p>
        @enderror

        <button>Check email</button>
      </form>
    </main>
  </body>
</html>

Replace routes/web.php with:

<?php

use App\Http\Controllers\SignupEmailController;
use Illuminate\Support\Facades\Route;

Route::get(
    '/signup',
    [SignupEmailController::class, 'create']
);

Route::post(
    '/signup/check-email',
    [SignupEmailController::class, 'store']
);

The Blade form includes Laravel's CSRF token. The POST route returns JSON after validation succeeds; a failed rule redirects back with the field error.

Run the form

php artisan serve

Open http://127.0.0.1:8000/signup and submit an address. A successful check returns:

{
  "accepted": true,
  "email": "person@example.com"
}

The address above is an input example, not a claim about a live mailbox. A blocking result returns to the form with the relevant field message.

Add one policy test

Create tests/Feature/SignupEmailTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class SignupEmailTest extends TestCase
{
    public function test_disposable_email_is_rejected(): void
    {
        Http::fake([
            'emailverifier.dev/api/v1/verify' =>
                Http::response([
                    'email' => 'temporary@example.test',
                    'status' => 'risky',
                    'action' => 'block',
                    'flagged' => true,
                    'signals' => [
                        'disposable_address',
                    ],
                    'suggestion' => null,
                ]),
        ]);

        $response = $this->from('/signup')->post(
            '/signup/check-email',
            [
                'email' => 'temporary@example.test',
            ]
        );

        $response->assertRedirect('/signup');
        $response->assertSessionHasErrors('email');

        Http::assertSentCount(1);
    }
}

Run it:

php artisan test --filter=SignupEmailTest

The fixture proves the application's mapping without spending a credit or relying on a domain's current classification.

Place account creation after validation

Replace the controller's JSON response with the existing user-creation transaction only after $request->validate returns. Keep the review behavior deliberate:

  • deliverable: the rule allows it, including deliverable role or catch-all results that your application may review separately.
  • risky: disposable addresses are blocked; a likely typo can produce a correction.
  • undeliverable: a confirmed syntax, routing, or recipient failure is blocked.
  • unknown: the rule allows it into email confirmation because recipient evidence was inconclusive.

If the Laravel application must persist review evidence rather than only block validation, use the state model in the production result-handling guide.

The REST API reference defines the exact result fields. The tutorial is complete when the browser form works, malformed input does not call the API, the fake disposable fixture fails validation, and accepted input reaches the controller after one request.

Continue reading