Laravel Signup Email Validation Tutorial
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.