Skip to content

Developers · 4 min read

Email Validation in PHP and Laravel: filter_var, DNS and APIs

How to do email validation in PHP: filter_var, MX checks with checkdnsrr, Laravel's email rule options, and when to call a verification API instead.

Jul 17, 2026 · SimpleCold team

A PHP function that trims the input, rejects anything over 254 characters and validates with filter_var, with example results

Email validation in PHP usually starts and ends with filter_var(). That's a reasonable start. It's also only the first of three layers you need if the address actually matters: format, domain, and mailbox.

This post goes through each layer in plain PHP, then shows the Laravel equivalents, then covers how to call a verification API for the mailbox check.

Layer 1: format with filter_var

PHP's built-in filter is better than most hand-written regexes:

<?php

function looksLikeEmail(string $input): bool
{
    $email = trim($input);

    if (strlen($email) > 254) {
        return false;
    }

    return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}

var_dump(looksLikeEmail('[email protected]'));   // true
var_dump(looksLikeEmail('jane@@example.com'));  // false
var_dump(looksLikeEmail('[email protected]')); // true

A few things worth knowing about FILTER_VALIDATE_EMAIL:

  • It doesn't accept internationalized (unicode) local parts by default. Pass FILTER_FLAG_EMAIL_UNICODE if you need them.
  • It accepts some technically valid but unusual forms, such as IP-literal domains in brackets. If that matters to you, add your own checks.
  • It doesn't trim. Always trim first, since copy-paste regularly brings trailing spaces.

If you'd rather use a regex, see our email validation regex post for a pattern that accepts plus signs and apostrophes.

Layer 2: does the domain receive mail?

A well-formed address on a domain with no mail server will bounce. PHP can check DNS directly:

<?php

function domainAcceptsMail(string $email): bool
{
    $domain = strtolower(substr(strrchr($email, '@'), 1));
    if ($domain === '') {
        return false;
    }

    // Convert internationalized domains to ASCII if the intl extension is available
    if (function_exists('idn_to_ascii')) {
        $ascii = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
        if ($ascii !== false) {
            $domain = $ascii;
        }
    }

    if (checkdnsrr($domain, 'MX')) {
        $records = dns_get_record($domain, DNS_MX) ?: [];
        // RFC 7505 null MX: a single record pointing at "." means "no mail"
        foreach ($records as $mx) {
            if (($mx['target'] ?? '') !== '' && $mx['target'] !== '.') {
                return true;
            }
        }
        return false;
    }

    // No MX: SMTP falls back to the A or AAAA record
    return checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
}

Two cautions. DNS lookups are slow compared with string checks, so don't run them on every keystroke. And a domain with valid MX records tells you nothing about whether the specific mailbox exists.

You can sanity-check any domain by hand with the MX lookup tool.

Layer 3: does the mailbox exist?

This is the SMTP check: connect to the domain's mail server, start a delivery, name the recipient, and read the response code. You can write it in PHP with fsockopen(). We don't recommend running it from your own servers in production, for practical reasons:

  • Many cloud hosts block outbound port 25 by default.
  • Mail servers rate-limit and block IPs that probe many addresses, and your web server's IP may end up on a blocklist.
  • Greylisting, catch-all domains and anti-spam systems all need handling, retries and state.
  • A web request waiting on a slow mail server makes for a slow signup form.

That's the part a verification API handles. Our post on how SMTP verification works explains the protocol if you're curious.

Calling a verification API from PHP

Here's a single-address check against the SimpleCold email verification API using cURL:

<?php

function verifyEmail(string $email, string $apiKey): array
{
    $url = 'https://simplecold.com/api/v1/api-validation/single?email=' . rawurlencode($email);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey],
        CURLOPT_TIMEOUT => 20,
    ]);

    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false || $status !== 200) {
        throw new RuntimeException("Verification request failed with HTTP $status");
    }

    $json = json_decode($body, true);
    return $json['payload'];
}

$result = verifyEmail('[email protected]', getenv('SIMPLECOLD_API_KEY'));

switch ($result['category']) {
    case 'deliverable':
        // accept
        break;
    case 'undeliverable':
        // reject, and show $result['suggestion'] if there is one
        break;
    case 'pending':
        // status is "processing": accept now, check again later
        break;
    default:
        // risky (catch-all, role-based, disposable, no response...): accept, maybe flag for review
}

The payload includes status (a specific code such as mailbox_found, mailbox_not_found or accept_all), statusMessage (a plain explanation), category and suggestion for likely typos. There are three categories plus pending; a server that won't confirm the address comes back risky with the status no_response. When a mail server is slow, the response comes back within about 12 seconds with status processing. The finished result is usually ready within a minute or two, and you can collect it any time in the next 30 minutes without using another credit. The handling processing results guide shows the retry pattern.

When the API says no

The function above throws on any non-200 response. In practice you'll want to tell the cases apart, because they call for different reactions:

HTTP statusTypical messageWhat to do
400A format error for the email fieldTreat the address as invalid
400Insufficient credits.Stop and alert someone; retrying won't help
401Invalid API key, API key is inactiveFix the configuration, alert
429Rate limit exceededWait for the Retry-After seconds, then retry; the single endpoint allows 100 calls a minute per key
5xxInternal Server ErrorRetry with backoff, or fail open

Error bodies carry the text in message. Rate-limit responses also repeat it in error for older integrations, and add code: "RATE_LIMITED" with details such as retryAfterSeconds in data. Read $json['message'] ?? $json['error'] to cover every shape. The error handling guide lists every message.

Collecting a pending result later

In Laravel, the natural place for the follow-up is a queued job dispatched with a delay. The job calls the result endpoint and stores whatever comes back:

$url = 'https://simplecold.com/api/v1/api-validation/single/result?email=' . rawurlencode($email);
// 200 with status "processing": release the job back to the queue for another minute
// 200 with any other status: save category, status and statusMessage on the user
// 404 "No check in progress for this email.": already collected, or the 30 minutes ran out

Collecting the result is free. Once you've read a finished result, the endpoint returns 404 for that address, so save it the first time.

Laravel

Laravel's email validation rule takes options that map onto the layers above:

$request->validate([
    'email' => ['required', 'string', 'max:254', 'email:rfc,dns'],
]);
OptionWhat it checks
rfcFormat according to the RFC (the default)
strictRFC format, rejecting some warnings
dnsThe domain has a valid MX record
spoofUnicode characters that could be used to spoof
filterPHP's filter_var

email:rfc,dns is a reasonable default for signup forms. To add a mailbox check, wrap the API call in a custom rule:

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class DeliverableEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        try {
            $result = verifyEmail($value, config('services.simplecold.key'));
        } catch (\Throwable $e) {
            return; // fail open if the API is unreachable
        }

        if ($result['category'] === 'undeliverable') {
            $hint = $result['suggestion'] ? " Did you mean {$result['suggestion']}?" : '';
            $fail("This email address can't receive mail.$hint");
        }
    }
}

Storing what you learn

Whatever the API returns, save it next to the user or contact record: the category, the status, the statusMessage and the date you checked. That costs one migration and pays off quickly.

It stops you paying twice. If the same person signs up again, logs in, or edits their profile without changing the address, you already know the answer and don't need another call. Re-verify when the address changes, or before a campaign to contacts you checked months ago.

It also makes support easier. When someone asks why their account was flagged, the stored reason ("This mailbox does not exist.") answers the question without anyone re-running a check. And it lets you report on sign-up quality over time, for example how many disposable addresses arrive from a particular landing page.

Keep the raw address the person typed, too. Normalising for comparison (trimming, lowercasing the domain) is fine, but send to the version they gave you.

Which layers to use where

SituationFormatDNSMailbox (API)
Newsletter signupYesYesYes, or confirmation email
Account signupYesYesYes
CheckoutYesYesYes, with typo suggestions
Internal admin formYesOptionalNo
Importing a CSVYesYesYes, in bulk

For CSV imports, use a bulk job instead of looping over single calls; the bulk job guide shows how. For a few dozen addresses at a time, the batch endpoint (POST /api-validation/batch with a JSON body of up to 50 addresses) sits in between.

While you build and test, use SimpleCold's test addresses instead of real ones. On the single and batch endpoints they return fixed results without using credits: [email protected] is deliverable, [email protected] is a missing mailbox and [email protected] is a catch-all. That lets you cover every branch of the switch above in a PHPUnit test. And if you're building in another language, we've covered JavaScript and Python too.

Check your list before you send it

Start with 200 free credits. Pay per credit after that.