Mailbeam
Laravel + PHPBeginner15 minutesUpdated January 2025

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 ValidMailbeamEmail custom validation rule (Laravel 10+)
  • An optional caching layer using Laravel's Cache facade
  • A SignupRequest Form 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_KEY in .env and set on production server
  • php artisan config:cache run after adding to config/services.php
  • Cache driver configured (CACHE_DRIVER=redis recommended in production)
  • failOpen=true (default) reviewed — understand the trade-off
  • Error messages translated if your app is multilingual

Next steps