Skip to content

Email Validator — PHP source

Validate email addresses one at a time or in bulk. Checks syntax, length limits, local-part and domain rules, plus-addressing, and IP-literal domains - all in your browser.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
declare(strict_types=1);

/**
 * email-validator — PHP polyglot showcase port.
 *
 * Language: PHP (8.0+; uses str_contains/str_starts_with/str_ends_with).
 *
 * CosmoDev polyglot showcase: the pure logic of the email-validator tool,
 * ported from src/lib/email-validator.ts. This file is display source — part
 * of CosmoDev's polyglot tool pages, where each tool's logic is shown side by
 * side in several languages.
 *
 * RFC 5321/5322-inspired email validation. Pure, deterministic, never throws.
 * Errs on the side of practical deliverability (provider-friendly) while still
 * recognising the legal-but-unusual forms (quoted local parts, IP-literal
 * domains).
 */

// RFC-inspired length ceilings: local part, domain, and total address.
const LOCAL_MAX = 64;
const DOMAIN_MAX = 253;
const TOTAL_MAX = 320;

/*
 * Characters permitted in an unquoted (atom) local part. PCRE with a `/`
 * delimiter: the literal slash inside the class is escaped as `\/`, and the
 * single quote is escaped for the PHP string as `\'`.
 */
const LOCAL_CHARS = '/^[A-Za-z0-9.!#$%&\'*+\/=?^_`{|}~-]+$/';

/**
 * Split an email into local + domain, honouring a quoted ("...") local part.
 *
 * Returns ['local' => ..., 'domain' => ..., 'quoted' => bool], or null when
 * the address cannot be split into exactly one '@' in the right place.
 */
function split_local_domain(string $email): ?array
{
    if ($email !== '' && $email[0] === '"') {
        // Walk the quoted string; a backslash escapes the next byte (so `\"`
        // does not terminate the quote).
        $len = strlen($email);
        $i = 1;
        while ($i < $len) {
            $ch = $email[$i];
            if ($ch === '\\') {
                $i += 2;
                continue;
            }
            if ($ch === '"') {
                break;
            }
            $i += 1;
        }
        if ($i >= $len || $email[$i] !== '"') {
            return null; // unterminated quote
        }
        $at = $i + 1;
        if ($at >= $len || $email[$at] !== '@') {
            return null; // '@' must immediately follow the closing quote
        }
        if (strpos($email, '@', $at + 1) !== false) {
            return null; // stray '@' inside the domain
        }
        return [
            'local'  => substr($email, 0, $at),
            'domain' => substr($email, $at + 1),
            'quoted' => true,
        ];
    }

    $first = strpos($email, '@');
    if ($first === false) {
        return null;
    }
    if (strpos($email, '@', $first + 1) !== false) {
        return null; // multiple '@'
    }
    return [
        'local'  => substr($email, 0, $first),
        'domain' => substr($email, $first + 1),
        'quoted' => false,
    ];
}

/**
 * True when $s is a dotted-quad: four octets, each 0–255, with no leading
 * zeros. The (int) cast clamps/overflows on huge values; comparing back to the
 * canonical decimal form rejects leading zeros ("01") and overflow forms.
 */
function is_ipv4(string $s): bool
{
    $parts = explode('.', $s);
    if (count($parts) !== 4) {
        return false;
    }
    foreach ($parts as $p) {
        if (!preg_match('/^\d+$/', $p)) {
            return false;
        }
        $n = (int) $p;
        if ($n < 0 || $n > 255 || (string) $n !== $p) {
            return false;
        }
    }
    return true;
}

/**
 * Push domain-level problems into the shared $reasons / $warnings arrays
 * (passed by reference).
 */
function validate_domain(string $domain, array &$reasons, array &$warnings): void
{
    if ($domain === '') {
        $reasons[] = 'Domain is empty';
        return;
    }
    if (strlen($domain) > DOMAIN_MAX) {
        $reasons[] = 'Domain exceeds ' . DOMAIN_MAX . ' characters';
    }

    // IP-literal domain: [1.2.3.4] or [IPv6:...].
    if ($domain[0] === '[' && substr($domain, -1) === ']') {
        $inner = substr($domain, 1, -1);
        if (preg_match('/^ipv6:/i', $inner)) {
            $warnings[] = 'IPv6 literal domain (uncommon; ensure your provider supports it)';
            return;
        }
        if (is_ipv4($inner)) {
            $warnings[] = 'IP-literal domain (uncommon; ensure your provider supports it)';
            return;
        }
        $reasons[] = 'Invalid IP-literal domain';
        return;
    }
    if ($domain[0] === '[' || substr($domain, -1) === ']') {
        $reasons[] = 'Malformed IP-literal domain (unmatched brackets)';
        return;
    }

    if (!str_contains($domain, '.')) {
        $reasons[] = 'Domain must contain at least one dot (e.g. example.com)';
        return;
    }

    $labels = explode('.', $domain);
    foreach ($labels as $label) {
        if ($label === '') {
            $reasons[] = 'Domain contains an empty label (consecutive or trailing dots)';
            continue;
        }
        if (strlen($label) > 63) {
            $reasons[] = 'Domain label exceeds 63 characters';
        }
        if (!preg_match('/^[A-Za-z0-9-]+$/', $label)) {
            $reasons[] = 'Domain label contains invalid characters';
        }
        if (str_starts_with($label, '-') || str_ends_with($label, '-')) {
            $reasons[] = 'Domain label starts or ends with a hyphen';
        }
    }
    // The TLD is the final label; require ≥2 ASCII letters so bare hostnames
    // and numeric tails are rejected.
    $tld = $labels[count($labels) - 1];
    if (!preg_match('/^[A-Za-z]{2,}$/', $tld)) {
        $reasons[] = 'Top-level domain must be at least two letters';
    }
}

/**
 * Validate a single email address; returns a structured verdict array, never
 * throws. `valid` is true iff `reasons` is empty; `warnings` never affect
 * validity.
 */
function validate_email(string $raw): array
{
    $reasons = [];
    $warnings = [];
    $email = trim($raw);

    if ($email === '') {
        return [
            'valid'      => false,
            'local'      => null,
            'domain'     => null,
            'normalized' => null,
            'reasons'    => ['Email is empty'],
            'warnings'   => $warnings,
        ];
    }

    if (strlen($email) > TOTAL_MAX) {
        $reasons[] = 'Email exceeds maximum length of ' . TOTAL_MAX . ' characters';
    }

    $split = split_local_domain($email);
    if ($split === null) {
        $reasons[] = 'Email must contain exactly one "@" separating local part and domain';
        return [
            'valid'      => false,
            'local'      => null,
            'domain'     => null,
            'normalized' => null,
            'reasons'    => $reasons,
            'warnings'   => $warnings,
        ];
    }

    ['local' => $local, 'domain' => $domain, 'quoted' => $quoted] = $split;

    if ($quoted) {
        // Quoted local parts are RFC-legal but almost universally rejected by
        // mailbox providers — warn, and only length-check structurally.
        if (strlen($local) > LOCAL_MAX) {
            $reasons[] = 'Local part exceeds ' . LOCAL_MAX . ' characters';
        }
        $warnings[] = 'Quoted local part (rarely supported by providers)';
    } elseif ($local === '') {
        $reasons[] = 'Local part is empty';
    } else {
        if (strlen($local) > LOCAL_MAX) {
            $reasons[] = 'Local part exceeds ' . LOCAL_MAX . ' characters';
        }
        if ($local[0] === '.' || substr($local, -1) === '.') {
            $reasons[] = 'Local part starts or ends with a dot';
        }
        if (str_contains($local, '..')) {
            $reasons[] = 'Local part contains consecutive dots';
        }
        if (!preg_match(LOCAL_CHARS, $local)) {
            $reasons[] = 'Local part contains invalid characters';
        }
    }
    // Plus-addressing (`user+tag@`) is valid and delivers to the base mailbox,
    // but callers filtering on exact address may want to know.
    if (!$quoted && str_contains($local, '+')) {
        $warnings[] = 'Plus-addressing (tag) detected — delivers to the base mailbox';
    }

    validate_domain($domain, $reasons, $warnings);

    $valid = count($reasons) === 0;
    return [
        'valid'      => $valid,
        'local'      => $local,
        'domain'     => $domain,
        'normalized' => ($local !== '' && $domain !== '') ? $local . '@' . strtolower($domain) : null,
        'reasons'    => $reasons,
        'warnings'   => $warnings,
    ];
}

/**
 * Validate many emails (one per line); blank/whitespace-only lines are
 * skipped. Line endings may be LF or CRLF.
 */
function validate_batch(string $input): array
{
    if ($input === '') {
        return [];
    }
    $lines = preg_split('/\r?\n/', $input);
    if ($lines === false) {
        return [];
    }
    $out = [];
    foreach ($lines as $line) {
        $line = trim($line);
        if ($line !== '') {
            $out[] = validate_email($line);
        }
    }
    return $out;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →