ContactLintAPIMCPPricingFree toolsCompareGet a key

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

  1. Get a free secret key at /signup and add it to .env:
    CONTACTLINT_KEY=your_secret_key
  2. Expose it through config/services.php, so it still works after php artisan config:cache:
    'contactlint' => [
        'key' => env('CONTACTLINT_KEY'),
    ],
  3. Create the rule with php artisan make:rule ContactLintEmail and replace app/Rules/ContactLintEmail.php with:
    <?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.');
            }
        }
    }
  4. 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

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
@enderror

When 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

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

Get a free API key. 100 checks per month, no card required. Or try a live check first.