Validar um endereço de e-mail em PHP costuma começar e terminar em uma linha:
filter_var($email, FILTER_VALIDATE_EMAIL). É nativo, é rápido e é
genuinamente útil — mas responde apenas à primeira das três perguntas que a
validação de verdade faz. O endereço tem o formato correto? O domínio dele
consegue receber e-mail? A caixa postal realmente existe? A biblioteca padrão
do PHP responde às duas primeiras de imediato; a terceira é um problema de rede
que vale a pena delegar. Este guia constrói a validação de e-mail em PHP em
camadas, com código funcional para cada uma, e mostra exatamente onde o
filter_var para.
A resposta curta#
Use filter_var() com FILTER_VALIDATE_EMAIL para a sintaxe, checkdnsrr() ou
getmxrr() para a consulta de MX e uma API de verificação para a checagem SMTP
da caixa postal — as mais baratas primeiro, interrompendo assim que uma delas
for decisiva. Não tente abrir uma sessão SMTP a partir do PHP para sondar caixas
postais por conta própria: a porta 25 de saída é bloqueada na maioria dos hosts,
e a resposta depende da reputação do IP remetente e do
greylisting que você não vai querer reimplementar.
Cada camada descarta endereços de forma mais barata que a anterior; só a última
consegue aprovar um endereço.
Camada 1: sintaxe com filter_var#
Para o PHP, o filter_var é a ferramenta certa para a camada um — ele te poupa
de escrever um regex à mão e é mais bem testado do que qualquer coisa que você
colaria do Stack Overflow. FILTER_VALIDATE_EMAIL retorna o endereço em caso de
sucesso e false em caso de falha, então compare de forma estrita:
function is_valid_syntax(string $email): bool
{
return strlen($email) <= 320
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
is_valid_syntax('jane@example.com'); // true
is_valid_syntax('not-an-email'); // false
is_valid_syntax('a@@b.com'); // false
O !== false estrito importa. O filter_var retorna a string filtrada em
caso de sucesso, não true, então um if (filter_var(...)) frouxo só funciona
porque uma string não vazia é truthy — comparar contra false diz exatamente o
que você quer dizer e sobrevive a refatorações. A checagem de comprimento é uma
segurança extra: um endereço com mais de 320 caracteres não pode ser real, e é
mais barato rejeitá-lo cedo do que entregar uma string gigante a qualquer coisa
lá na frente.
Saiba o que esse filtro faz e o que não faz. Por padrão, ele valida contra uma
gramática derivada da RFC e rejeita partes locais internacionalizadas (Unicode)
— então 用户@example.com falha, a menos que você adicione a flag
FILTER_FLAG_EMAIL_UNICODE no PHP 7.1 ou superior. É um filtro de sintaxe,
ponto final: nunca toca no DNS, nunca abre um socket e não faz ideia se
example.com existe. Note também que FILTER_SANITIZE_EMAIL é outra história —
ele remove caracteres não permitidos e retorna uma string alterada em vez de uma
resposta de sim ou não, então não recorra a ele quando o que você quer é validar.
Por que passar no filter_var não é sinal verde#
Passar no filter_var significa que a string tem o formato de um endereço de
e-mail. Não diz nada sobre a mensagem chegar ou não. definitely-fake@gmail.com
passa. info@company-that-folded.com passa. typo@gmial.com passa. Todos os
três são inentregáveis, e nenhuma quantidade de correspondência de padrões — o
filter_var ou o regex mais elaborado que você conseguir encontrar — jamais vai
te dizer isso, porque sintaxe e entregabilidade são perguntas diferentes. Uma é
um fato sobre a string; a outra é um fato sobre a internet. Essa é a mesma
armadilha que faz a
validação de e-mail por regex falhar,
e o filter_var fica exatamente do mesmo lado da linha.
Se você quiser ver a diferença por conta própria, cole um endereço
sintaticamente perfeito no verificador de e-mail gratuito
e veja uma string aprovada pelo filter_var voltar como undeliverable.
Camada 2: o domínio consegue receber e-mail?#
É aqui que o PHP justifica seu valor sem nenhuma dependência. Um domínio sem
rota de e-mail não consegue aceitar mensagens para ninguém, então essa única
consulta elimina domínios mortos, nomes de empresas escritos errado e TLDs
inventados. A checagem mais rápida é checkdnsrr(), que retorna um booleano
para saber se um determinado tipo de registro existe:
function has_mail_route(string $domain): bool
{
// checkdnsrr() returns true if the domain publishes at least one MX record.
// Fall back to A for domains that accept mail on an implicit MX.
return checkdnsrr($domain, 'MX') || checkdnsrr($domain, 'A');
}
has_mail_route('gmail.com'); // true
has_mail_route('company-that-folded.com'); // false
Separe o domínio do endereço com strrpos, para sempre cortar no último @:
$domain = substr($email, strrpos($email, '@') + 1);
Quando você precisar dos servidores de e-mail de fato, em vez de um sim ou não —
digamos, para registrá-los em log ou inspecionar prioridades — recorra ao
getmxrr(). Ele preenche um array de hosts e um array de pesos correspondente
por referência e retorna false quando não há nenhum registro MX:
function mail_hosts(string $domain): array
{
$hosts = [];
$weights = [];
if (!getmxrr($domain, $hosts, $weights)) {
return [];
}
array_multisort($weights, $hosts); // lowest weight = highest priority
return $hosts; // e.g. ['gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com', ...]
}
Camada 3: a caixa postal realmente existe?#
As camadas um e dois só conseguem descartar um endereço. Um domínio pode
publicar registros MX perfeitos e ainda assim não ter nenhuma caixa postal no
endereço que você tem em mãos — noreply-9f2x@gmail.com tem sintaxe válida em um
domínio com uma rota de e-mail ativa, e mesmo assim é uma caixa postal que nunca
foi criada. Confirmar que uma caixa postal específica existe significa a conversa
de entrega SMTP: conectar ao servidor de e-mail, emitir RCPT TO, ler a resposta
e desconectar antes de enviar qualquer coisa. Há mais nisso —
como funciona a verificação de e-mail
percorre o pipeline completo, incluindo os
domínios catch-all que aceitam todo endereço e
derrotam um teste ingênuo.
Em princípio, dá para programar isso em PHP com fsockopen() e comandos SMTP
crus. Na prática, você não deveria executá-lo a partir do seu servidor de
aplicação: a maioria dos provedores de nuvem bloqueia a porta 25 de saída, a
resposta depende da reputação do IP a partir do qual você conecta, e os
servidores receptores fazem greylisting e limitam a taxa de remetentes
desconhecidos — então um teste que funciona num teste local falha silenciosamente,
ou te coloca em uma blocklist, em produção. É esta a camada que vale a pena
delegar.
Fazendo a checagem completa com uma API#
O POST /verify da Qualisend roda o pipeline inteiro — sintaxe,
DNS e o teste SMTP da caixa postal — a partir de uma infraestrutura com reputação
gerenciada, construída para isso, e retorna um veredito. Sem nenhum framework, um
pequeno helper cURL cobre todas as chamadas que você vai fazer:
const QUALISEND_BASE = 'https://app.qualisend.com/api/v1';
function qualisend(string $method, string $path, ?array $body = null): array
{
$ch = curl_init(QUALISEND_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('QUALISEND_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
CURLOPT_TIMEOUT => 10,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false || $status >= 400) {
throw new RuntimeException("Qualisend responded {$status}");
}
return json_decode($raw, true);
}
function verify(string $email): array
{
return qualisend('POST', '/verify', ['email' => $email]);
}
Se você já usa o Guzzle, a mesma requisição fica algumas linhas mais curta e cuida do JSON e dos códigos de status de erro para você:
use GuzzleHttp\Client;
$http = new Client(['base_uri' => 'https://app.qualisend.com/api/v1/']);
$response = $http->post('verify', [
'headers' => ['Authorization' => 'Bearer ' . getenv('QUALISEND_API_KEY')],
'json' => ['email' => 'jane@example.com'],
'timeout' => 10,
]);
$result = json_decode((string) $response->getBody(), true);
As checagens locais voltam imediatamente, com o teste SMTP na fila:
{
"job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
"probe_queued": true,
"result": {
"email": "jane@example.com",
"status": "deliverable",
"score": 95,
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false },
"did_you_mean": null,
"smtp": "pending",
"reason": null
}
}
Para a validação de e-mail ao vivo no cadastro, o result imediato costuma ser
suficiente para agir — rejeite undeliverable, ofereça a correção
did_you_mean, sinalize disposable. Quando você precisar do veredito
confirmado por SMTP, faça polling no job até o teste terminar. Isso só é
necessário enquanto result.smtp for pending; cached ou skipped significa
que o primeiro result já é definitivo. Rode o loop em um worker ou fila em
segundo plano, nunca dentro da requisição de cadastro, e não chame /verify de
novo para atualizar um endereço:
function verify_and_wait(string $email, int $tries = 10, float $delay = 1.5): ?array
{
$response = verify($email);
if ($response['result']['smtp'] !== 'pending') {
return $response['result']; // cached or skipped: already final
}
$jobId = $response['job_id'];
for ($i = 0; $i < $tries; $i++) {
$job = qualisend('GET', "/jobs/{$jobId}?include=results");
if ($job['status'] === 'completed') {
return $job['results'][0]; // ['status' => ..., 'score' => ..., 'reason' => ...]
}
if ($job['status'] === 'canceled' || $job['status'] === 'failed') {
return null; // the job ended without a verdict
}
usleep((int) ($delay * 1_000_000));
}
return null; // still processing — treat as unknown, retry later
}
Juntando as camadas#
As mais baratas primeiro, pare assim que tiver uma resposta:
function validate_email(string $email): array
{
if (!is_valid_syntax($email)) {
return ['status' => 'undeliverable', 'reason' => 'invalid_email'];
}
$domain = substr($email, strrpos($email, '@') + 1);
if (!has_mail_route($domain)) {
return ['status' => 'undeliverable', 'reason' => 'invalid_domain'];
}
try {
return verify($email)['result']; // deliverable | risky | undeliverable | unknown
} catch (RuntimeException $e) {
// Timeout or API error (e.g. 402, 429, 5xx): fail open, re-verify later.
return ['status' => 'unknown', 'reason' => 'unknown'];
}
}
As duas camadas locais não custam nada e pegam a maior parte do lixo na hora; a camada da API roda apenas em endereços que valem a ida e volta pela rede. Essa ordenação é o truque inteiro — o mesmo formato que você vai encontrar nas versões Node.js e Python deste guia, porque é a divisão em camadas, e não a linguagem, o que faz a validação funcionar.
Perguntas frequentes
01O filter_var é suficiente para validar um endereço de e-mail em PHP?
Para a sintaxe, sim - filter_var($email, FILTER_VALIDATE_EMAIL) é a checagem certa para a camada um e uma aposta melhor do que um regex feito à mão. Mas ele valida o formato, não a entregabilidade: nunca resolve DNS nem contata um servidor de e-mail, então passar significa "parece um e-mail", não "será entregue". Combine-o com uma consulta de MX e uma checagem SMTP da caixa postal antes de confiar no endereço.
02Qual é a diferença entre checkdnsrr() e getmxrr()?
checkdnsrr() responde a uma pergunta de sim ou não - o domínio publica um registro de um determinado tipo? - e retorna um booleano, que é tudo o que você precisa para confirmar que existe uma rota de e-mail. getmxrr() vai além: preenche arrays com os nomes de host MX reais e seus pesos por referência, então use-o quando quiser inspecionar ou ordenar os servidores de e-mail em vez de apenas confirmar que eles existem. Ambos retornam false quando não há registro correspondente.
03Posso verificar uma caixa postal em PHP sem um serviço externo?
Em parte. checkdnsrr() e getmxrr() confirmam que o domínio aceita e-mail, o que descarta domínios mortos de graça e não precisa de nada além da biblioteca padrão. Confirmar a caixa postal significa uma conversa SMTP, que você pode tentar com fsockopen(), mas não deve executar a partir do seu servidor de aplicação - a porta 25 é bloqueada em larga escala e o resultado depende da reputação do seu IP. É essa a camada que um serviço de verificação existe para resolver.
04Devo rodar essas checagens no cadastro ou ao limpar uma lista?
Ambos, em profundidades diferentes. Rode a sintaxe e a consulta de MX de forma síncrona no cadastro - elas são rápidas o suficiente para bloquear a requisição e dar retorno instantâneo - e aja também sobre o veredito imediato da API ali. Reserve o resultado completo confirmado por SMTP para a limpeza de listas e o trabalho de retaguarda mais lento; o guia de cadastro serverless mostra o padrão de ponta a ponta.
Pronto para adicionar a camada SMTP? O plano gratuito inclui 100
créditos que rodam o pipeline completo, e a
referência da API traz os endpoints /verify e /jobs completos
com exemplos de copiar e colar em sete linguagens.



