Laravel's built-in email rule is a good start. With email:rfc,dns it even checks that the domain has DNS records. What it can't do is tell you that gmial.com is a typo, that the domain is a throwaway inbox provider, or that the mailbox doesn't exist.
In this post I'll wire email verification into a Laravel app the way I'd do it in production:
- A custom validation rule that runs a free, sub-second fast check on signup.
- Fail open on timeouts and errors, so a third-party hiccup never blocks a real user.
- A queued job that runs the full mailbox check after the account is created.
- Config, env and caching so it's easy to test and cheap to run.
There's also a plain PHP version with curl at the end, for apps that aren't on Laravel.
The two checks
The MailRambo API has two modes on the same endpoint, POST https://www.mailrambo.com/v1/verify:
Fast (?mode=fast) |
Full (default) | |
|---|---|---|
| Cost | Free | 1 credit |
| Typical time | well under a second | usually 1-5 seconds |
| Checks | Syntax, typos, MX, disposable, role flags | Everything, plus the mailbox and catch-all |
deliverable |
false or null |
true or false |
| Use it for | Validating the signup form | Confirming before you send |
Fast mode can prove an address is bad but never that it's good, which is exactly what a form validator needs: reject provable problems, let everything else through. The full check is too slow to sit in a request that a user is waiting on, so it goes in a queue.
Configuration
Add the key to .env:
MAILRAMBO_KEY=mr_test_xxxxxxxxxxxx
Use a test key (mr_test_...) locally and in CI. Test keys never contact mail servers and never use credits; for full checks the answer depends on the part before the @ (deliverable@example.com, disposable@example.com, catch_all@example.com, and so on). Fast mode with a test key runs the real fast checks, which are free anyway.
Then expose it through config/services.php, so you never call env() outside config files (it returns null once config is cached):
// config/services.php
'mailrambo' => [
'key' => env('MAILRAMBO_KEY'),
'timeout' => 3,
],
A validation rule for signup
Create the rule with php artisan make:rule VerifiedEmail, then fill it in:
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class VerifiedEmail implements ValidationRule
{
private const MESSAGES = [
'invalid_syntax' => 'Please enter a valid email address.',
'no_mx' => "That domain can't receive email. Check for typos?",
'disposable' => 'Please use a permanent email address.',
];
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$email = strtolower(trim((string) $value));
$result = Cache::remember('mr:fast:' . sha1($email), now()->addDay(), fn () => $this->check($email));
if ($result === null || ($result['deliverable'] ?? null) !== false) {
return; // passed, or skipped (fail open)
}
$reason = $result['reason'] ?? null;
if ($reason === 'possible_typo' && ! empty($result['suggestion'])) {
$fail("Did you mean {$result['suggestion']}?");
return;
}
$fail(self::MESSAGES[$reason] ?? 'Please use a different email address.');
}
private function check(string $email): ?array
{
try {
$res = Http::withToken(config('services.mailrambo.key'))
->timeout(config('services.mailrambo.timeout'))
->acceptJson()
->post('https://www.mailrambo.com/v1/verify?mode=fast', ['email' => $email]);
if ($res->failed()) {
Log::warning('mailrambo fast check skipped', ['status' => $res->status(), 'error' => $res->json('error')]);
return null;
}
return $res->json();
} catch (\Throwable $e) {
Log::warning('mailrambo fast check skipped', ['error' => $e->getMessage()]);
return null;
}
}
}
A few choices worth calling out:
- Fail open. Timeouts, connection errors and non-2xx responses return
null, and the rule passes. Blocking a real signup because a third party was slow is a worse outcome than letting one bad address in. - Don't cache failures.
Cache::rememberdoesn't storenull, so a skipped check is retried next time instead of being remembered as "fine". - Typos ask, they don't fix. The rule fails with "Did you mean ...?" and the user corrects the field. I wouldn't silently rewrite the address on the server: if the guess is wrong, you've created an account for someone else's inbox.
Use it next to Laravel's own rules:
$request->validate([
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new VerifiedEmail],
'password' => ['required', 'confirmed', Password::defaults()],
]);
Put it last, so cheap local checks (and the unique query) run first. By default Laravel keeps running the remaining rules on an attribute after one fails; add bail at the start of the array if you'd rather skip the API call when an earlier rule already failed.
A queued full check after signup
The full check confirms the mailbox. Run it in a job once the user is created:
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class VerifyUserEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [30, 120];
public function __construct(public User $user) {}
public function handle(): void
{
$res = Http::withToken(config('services.mailrambo.key'))
->timeout(30)
->acceptJson()
->post('https://www.mailrambo.com/v1/verify', ['email' => $this->user->email]);
if ($res->status() === 402) {
// insufficient_credits: don't retry, just flag it for yourself
report(new \RuntimeException('MailRambo out of credits'));
return;
}
$res->throw(); // 429 and 503 throw, so the queue retries with backoff
$this->user->forceFill([
'email_deliverable' => $res->json('deliverable'),
'email_reason' => $res->json('reason'),
])->save();
}
}
Dispatch it after registration, for example in your Registered event listener or right after User::create():
VerifyUserEmail::dispatch($user);
Retrying is safe on cost: a 503 provider_unavailable refunds the credit automatically, and repeat checks of the same address from the same account within 24 hours are free (the response includes "cached": true).
What you do with the result is up to you. I'd use it to gate marketing sends and to flag accounts for review, not to lock people out after the fact. Add the two columns with a migration (email_deliverable as a nullable boolean, email_reason as a nullable string).
How to handle each reason
reason |
deliverable |
At signup (fast) | After signup (full) |
|---|---|---|---|
mailbox_exists |
true | n/a | Good to send |
role_account |
true | n/a | Deliverable team inbox (info@); fine for product email |
fast_check_passed |
null | Allow | n/a |
possible_typo |
false | Show "Did you mean ...?" | n/a |
invalid_syntax, no_mx |
false | Reject | Don't send |
disposable |
false | Reject (your call) | Don't send, flag |
mailbox_not_found, mailbox_disabled |
false | n/a | Don't send, ask user to update |
catch_all, unverifiable |
false | n/a | Can't be confirmed; transactional only |
inbox_full, spamtrap |
false | n/a | Don't send |
For the full list and errors (401, 402, 429, 503), see the developers page.
Plain PHP with curl
No framework, same logic:
<?php
function mailrambo_fast_check(string $email): ?array
{
$ch = curl_init('https://www.mailrambo.com/v1/verify?mode=fast');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MAILRAMBO_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $status !== 200) {
return null; // fail open
}
return json_decode($body, true);
}
$result = mailrambo_fast_check($_POST['email'] ?? '');
if ($result !== null && $result['deliverable'] === false) {
$error = $result['reason'] === 'possible_typo'
? 'Did you mean ' . htmlspecialchars($result['suggestion']) . '?'
: 'Please use a different email address.';
}
Remember to escape the suggestion before printing it into HTML.
Summary
- Keep Laravel's
emailrule, then add a fast check for typos, disposable domains and dead domains. - Fail open, cache successes, and never auto-correct the address.
- Queue the full check after signup and store
deliverableandreasonon the user.
Fast mode is free on every plan, and the Free plan includes 100 full checks a month. Grab a test key and the request details from the developers page.