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_keySet 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:cacheafter 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.