Home / Integrations / Laravel
Validate Signup Emails in Laravel With a Custom Rule
Published 2026-10-18
This guide adds a custom Laravel validation rule that checks each signup email with the ContactLint API before the user is saved. It catches typos like gmial.com, domains that cannot receive mail, and disposable addresses. If ContactLint is slow or unreachable, the rule lets the signup through.
Where the check goes in Laravel
Put the check in a custom validation rule. Laravel runs it on the server when the form is submitted, so nobody can skip it by editing the page or posting to your route directly. Your secret key stays in .env and never reaches the browser.
The rule sends one request to POST https://contactlint.com/v1/email/validate and turns the answer into a normal validation error. Your controller, form requests and Blade templates keep working as they do now: errors land in $errors and old() refills the form.
Set up the rule
- Get a free secret key at /signup and add it to
.env:CONTACTLINT_KEY=your_secret_key
- Expose it through
config/services.php, so it still works afterphp artisan config:cache:'contactlint' => [ 'key' => env('CONTACTLINT_KEY'), ], - Create the rule with
php artisan make:rule ContactLintEmailand replaceapp/Rules/ContactLintEmail.phpwith:<?php namespace App\Rules; use Closure; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Throwable; class ContactLintEmail implements ValidationRule, DataAwareRule { protected array $data = []; public function setData(array $data): static { $this->data = $data; return $this; } public function validate(string $attribute, mixed $value, Closure $fail): void { try { $response = Http::withToken((string) config('services.contactlint.key')) ->acceptJson() ->timeout(3) ->post('https://contactlint.com/v1/email/validate', ['email' => $value]); } catch (Throwable $e) { Log::warning('ContactLint unreachable, accepting email', ['error' => $e->getMessage()]); return; // timeout or network error: fail open } if (! $response->ok()) { Log::warning('ContactLint returned HTTP '.$response->status().', accepting email'); return; // 401, 402, 429 or other error: fail open } $result = $response->json('result'); if (! is_array($result)) { return; } if (($result['verdict'] ?? null) === 'invalid') { $fail('That email address cannot receive mail.'); return; } // Check the typo suggestion before disposable: gmial.com is both. $typoConfirmed = ! empty($this->data['email_typo_ok']); if (! empty($result['did_you_mean']) && ! $typoConfirmed) { $fail('Did you mean '.$result['did_you_mean'].'?'); return; } if (! empty($result['disposable'])) { $fail('Please use a permanent email address.'); } } } - Use it in your registration controller, or in the
rules()method of a form request:use App\Rules\ContactLintEmail; $validated = $request->validate([ 'email' => ['bail', 'required', 'email', 'max:255', 'unique:users', new ContactLintEmail], 'password' => ['required', 'confirmed', 'min:8'], ]);
bail stops at the first failing rule, so you do not spend a check on an address that is malformed or already taken. The rule uses the ValidationRule and DataAwareRule interfaces. If your Laravel release does not have ValidationRule, check the Laravel docs for the rule format your version uses.
Handle each verdict and did_you_mean in Blade
- invalid: the address is malformed or its domain has no MX or A record. The rule fails with "That email address cannot receive mail."
- risky with
did_you_meanset: the rule fails with "Did you mean jane@gmail.com?" so the person can fix it. - risky with reason
disposable: the rule fails. Remove that branch if you do not need a permanent address. - risky with reason
role_address, valid and unknown: the rule passes.
Some real addresses look like typos. Show a checkbox when the suggestion appears, so the person can keep what they typed:
<input type="email" name="email" value="{{ old('email') }}" required>
@error('email')
<p class="error">{{ $message }}</p>
@if (str_starts_with($message, 'Did you mean'))
<label>
<input type="checkbox" name="email_typo_ok" value="1">
My address is correct as typed
</label>
@endif
@enderrorWhen the box is checked, the rule skips the typo branch but still runs the disposable branch. A typo domain that is also on the disposable list, such as gmial.com, is still rejected with the disposable message.
Want the hint before the form is submitted? Add the drop-in widget with a cl_pub_ publishable key and keep this rule as the backstop.
Test it
Try the rule against the live API in php artisan tinker. Each call uses one check.
Validator::make(['email' => 'jane@gmial.com'], ['email' => [new App\Rules\ContactLintEmail]])->errors()->all(); // ["Did you mean jane@gmail.com?"] Validator::make(['email' => 'x@mailinator.com'], ['email' => [new App\Rules\ContactLintEmail]])->errors()->all(); // ["Please use a permanent email address."]
In automated tests, fake the HTTP client so you spend no checks. This test confirms the rule fails open on a server error:
use App\Rules\ContactLintEmail;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
public function test_it_fails_open_when_contactlint_errors(): void
{
Http::fake(['contactlint.com/*' => Http::response([], 500)]);
$v = Validator::make(['email' => 'x@mailinator.com'], ['email' => [new ContactLintEmail]]);
$this->assertTrue($v->passes());
}
Limits and failing open
- ContactLint does not connect to mail servers. It cannot confirm that a mailbox exists or detect catch-all domains. It checks syntax, MX and A records, known disposable domains, role addresses and typos of major providers. See the server-side guide for details.
- The rule fails open. A timeout, network error,
401(missing or invalid key),402(no free checks or credits left) or429(more than 60 requests per minute on the key) all let the signup through. Watch your logs for the warnings, because a wrong key fails open silently otherwise. - Each key gets 100 free checks per month, then credit packs from $9 for 5,000. A check that ends as
unknownbecause the DNS lookup failed is not billed.
Questions
Does the rule slow down my signup form?
It adds one HTTP request when the form is submitted. The request is capped at 3 seconds, and if ContactLint does not answer in time the rule passes and the signup continues.
Does every form submission use a check?
Every time the rule runs it uses one check. Put bail and the cheap rules (required, email, unique) first so malformed or taken addresses never reach ContactLint. A check that ends as unknown because the DNS lookup failed is not billed.
Can I use the rule in a Form Request?
Yes. Add new ContactLintEmail to the email field in the rules() method. Laravel passes the request data to the rule through DataAwareRule, so the email_typo_ok checkbox works the same way.
Other integrations
- Next.js
- Express
- Django
- FastAPI
- Ruby on Rails
- Supabase
- Firebase Authentication
- WordPress
- Webflow
- Zapier
- Make
- n8n
Get a free API key. 100 checks per month, no card required. Or try a live check first.