Guides /

Laravel

PHP validation rule

A custom validation rule you add to the signup request's email field, next to required and email.

The code

1 Keep the key on the server

MAILVET_KEY=mv_live_your_key

Set it wherever your server reads its environment. Never put it in client code.

2 Add it to the services config

config/services.php

'mailvet' => [
    'key' => env('MAILVET_KEY'),
],

3 Write the rule

app/Rules/MailvetEmail.php

<?php

namespace App\Rules;

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

class MailvetEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        try {
            $res = Http::withToken(config('services.mailvet.key'))
                ->connectTimeout(2)
                ->timeout(5)
                ->get('https://mailvet.dev/v1/check', ['email' => $value]);
        } catch (ConnectionException $e) {
            Log::warning('mailvet: no answer: '.$e->getMessage());
            return; // a signup shouldn't die because of us
        }

        if (! $res->successful()) {
            Log::warning('mailvet: HTTP '.$res->status());
            return;
        }

        $hint = $res->json('did_you_mean');
        if ($hint) {
            $local = substr($value, 0, strrpos($value, '@'));
            $fail("Did you mean {$local}@{$hint}?");
        } elseif ($res->json('verdict') === 'invalid') {
            $fail("That address can't receive mail.");
        } elseif ($res->json('verdict') === 'risky' && $res->json('disposable')) {
            $fail('Please use a permanent address, not a throwaway one.');
        }
    }
}

4 Use it on the signup request

app/Http/Controllers/SignupController.php

use App\Rules\MailvetEmail;

$validated = $request->validate([
    'email' => ['bail', 'required', 'email', 'unique:users', new MailvetEmail],
]);

bail stops at the first failure, so an address that's already taken doesn't spend a check.

What to do with the answer

did_you_mean, invalid and disposable call $fail, and the message appears in the normal validation errors. Role addresses and a risky from a DNS failure pass.

  • valid Let it through.
  • risky Read disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through.
  • invalid Bad format, or the domain has no mail server. Reject.

did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.

If MailVet is down, the signup goes through

A timeout or connection failure throws ConnectionException, which the rule catches. Non-2xx answers don't throw, so the successful() check handles those. Both log and return without calling $fail, so validation passes.

A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.

Watch out

  • Add the rule to the signup request only. On a profile form it spends a check per save.
  • If you cache config in production, run php artisan config:cache after adding this, or the key won't be picked up.

Sources

Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.

Other guides

Get API key