Email Verification in Laravel
This tutorial integrates Mailbeam into a Laravel application as a custom validation rule. The rule hooks into Laravel's existing validation system, so you can use it anywhere you'd use required or email.
What you'll build
- A
ValidMailbeamEmailcustom validation rule (Laravel 10+) - An optional caching layer using Laravel's Cache facade
- A
SignupRequestForm Request using the rule - PHPUnit tests
Prerequisites
- PHP 8.1+, Laravel 10+
- Composer
- A Mailbeam API key (sign up free)
Step 1 — Configure the API key
Add to config/services.php:
'mailbeam' => [
'key' => env('MAILBEAM_KEY'),
'base_url' => 'https://api.mailbeam.dev',
],Add to .env:
MAILBEAM_KEY=mb_live_xxxxxxxxxxxxxxxxxxxx
Step 2 — Create the validation rule
php artisan make:rule ValidMailbeamEmail<?php
// app/Rules/ValidMailbeamEmail.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ValidMailbeamEmail implements ValidationRule
{
public function __construct(
private int $minScore = 60,
private bool $failOpen = true
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$email = strtolower(trim((string) $value));
$cacheKey = 'mailbeam_' . md5($email);
// Check cache first
$result = Cache::remember($cacheKey, now()->addHour(), function () use ($email) {
return $this->callMailbeam($email);
});
if ($result === null) {
// API error — fail open (don't reject email)
return;
}
$valid = $result['valid'] ?? false;
$score = $result['score'] ?? 0;
$reason = $result['reason'] ?? null;
if (!$valid || $score < $this->minScore) {
$message = $this->getErrorMessage($reason);
$fail($message);
}
}
private function callMailbeam(string $email): ?array
{
try {
$response = Http::withToken(config('services.mailbeam.key'))
->timeout(5)
->post(config('services.mailbeam.base_url') . '/v1/verify', [
'email' => $email,
]);
if ($response->successful()) {
return $response->json();
}
Log::warning('Mailbeam API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return $this->failOpen ? null : ['valid' => false, 'score' => 0, 'reason' => 'api_error'];
} catch (\Throwable $e) {
Log::error('Mailbeam request failed', ['error' => $e->getMessage()]);
return $this->failOpen ? null : ['valid' => false, 'score' => 0, 'reason' => 'api_error'];
}
}
private function getErrorMessage(?string $reason): string
{
return match ($reason) {
'disposable_domain' => 'Please use a permanent email address, not a temporary one.',
'no_mx_records' => 'This email domain does not appear to accept mail.',
'smtp_rejected' => 'This email address does not appear to exist.',
'role_address' => 'Please use a personal email address.',
default => 'Please provide a valid, reachable email address.',
};
}
}Step 3 — Create a Form Request
php artisan make:request SignupRequest<?php
// app/Http/Requests/SignupRequest.php
namespace App\Http\Requests;
use App\Rules\ValidMailbeamEmail;
use Illuminate\Foundation\Http\FormRequest;
class SignupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'email:rfc,dns', new ValidMailbeamEmail()],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'password_confirmation' => ['required', 'string'],
];
}
public function messages(): array
{
return [
'email.email' => 'Please enter a valid email format.',
];
}
}Step 4 — Use in your controller
<?php
// app/Http/Controllers/Auth/SignupController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\SignupRequest;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class SignupController extends Controller
{
public function store(SignupRequest $request)
{
// $request->validated() only runs after all rules pass
$validated = $request->validated();
$user = User::create([
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
auth()->login($user);
return redirect('/dashboard');
}
}Route:
// routes/web.php
Route::post('/signup', [SignupController::class, 'store'])->name('signup');Testing
<?php
// tests/Feature/SignupTest.php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Support\Facades\Http;
use Illuminate\Foundation\Testing\RefreshDatabase;
class SignupTest extends TestCase
{
use RefreshDatabase;
public function test_valid_email_creates_account(): void
{
Http::fake([
'api.mailbeam.dev/*' => Http::response([
'valid' => true, 'score' => 94, 'reason' => null,
], 200),
]);
$response = $this->post('/signup', [
'email' => 'user@example.com',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$response->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', ['email' => 'user@example.com']);
}
public function test_disposable_email_is_rejected(): void
{
Http::fake([
'api.mailbeam.dev/*' => Http::response([
'valid' => false, 'score' => 2, 'reason' => 'disposable_domain',
], 200),
]);
$response = $this->post('/signup', [
'email' => 'temp@mailinator.com',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$response->assertSessionHasErrors('email');
$this->assertDatabaseMissing('users', ['email' => 'temp@mailinator.com']);
}
public function test_api_failure_does_not_block_signup(): void
{
Http::fake([
'api.mailbeam.dev/*' => Http::response([], 500),
]);
// With failOpen=true (default), a 500 from Mailbeam should not block signup
$response = $this->post('/signup', [
'email' => 'user@example.com',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$response->assertRedirect('/dashboard');
}
}Production checklist
-
MAILBEAM_KEYin.envand set on production server -
php artisan config:cacherun after adding toconfig/services.php - Cache driver configured (
CACHE_DRIVER=redisrecommended in production) -
failOpen=true(default) reviewed — understand the trade-off - Error messages translated if your app is multilingual