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 →