PHP Validar Dados

Um formulário é uma porta aberta: qualquer pessoa, ou qualquer programa, pode mandar qualquer coisa para o seu PHP. Nome em branco, e-mail sem arroba, idade "vinte", idade 150, um array onde você esperava texto. Validar é conferir cada dado antes de usar, e responder com educação quando algo está errado. Nesta aula você monta um formulário de cadastro completo: confere os campos, mostra a mensagem de erro ao lado de cada um e não obriga a pessoa a digitar tudo de novo.

Você vai aprender
  • Por que a conferência do navegador (required, type="email") não basta.
  • Como tratar campos obrigatórios com trim (tirar os espaços das pontas).
  • Como usar filter_var para e-mails (FILTER_VALIDATE_EMAIL) e números inteiros (FILTER_VALIDATE_INT), inclusive com faixa mínima e máxima.
  • Por que comparar com === false (a armadilha do zero).
  • Como guardar os erros num array e mostrar cada mensagem ao lado do seu campo.
  • Como manter os valores que a pessoa digitou quando há erro.
  • A diferença entre validar no cliente (navegador) e no servidor (PHP).

A ideia: o porteiro e a recepcionista

Pense num prédio com duas pessoas na entrada. A recepcionista fica na calçada e avisa, gentilmente: "o senhor esqueceu de preencher o nome". Ela ajuda quem está de boa-fé, mas qualquer um pode passar direto por ela. O porteiro fica na porta, confere o documento de todo mundo, e sem ele ninguém entra.

  • A recepcionista é a validação no navegador (atributos required, type="email", min, max, JavaScript): rápida e simpática, mas fácil de pular.
  • O porteiro é a validação no PHP: a única que vale de verdade.

Prova: pulando a recepcionista

A Ana fez um formulário com tudo o que o HTML oferece:

<form method="post" action="cadastro.php">
  <input name="nome" required maxlength="60">
  <input type="email" name="email" required>
  <input type="number" name="idade" min="1" max="120" required>
  <button>Cadastrar</button>
</form>

No navegador, ninguém consegue enviar o nome vazio nem a idade 150. Mas o curl não é um navegador: ele manda o pedido direto para o cadastro.php, sem passar pelo formulário. E o PHP recebe exatamente o que foi mandado (aqui, com um var_dump($_POST)):

$ curl -s localhost:8000/dump.php -d "nome=&email=isso+nao+e+email&idade=150"
array(3) {
  ["nome"]=>
  string(0) ""
  ["email"]=>
  string(16) "isso nao e email"
  ["idade"]=>
  string(3) "150"
}

Do mesmo jeito, qualquer pessoa pode abrir as ferramentas do navegador (F12), apagar o required e enviar. Por isso a regra:

Regra de ouro da validação: trate tudo o que vem de $_GET, $_POST, $_COOKIE e $_FILES como suspeito, até o PHP conferir. O navegador ajuda o visitante; o servidor protege o sistema.

Passo 1: limpar com trim

Um campo "preenchido" só com espaços parece cheio para o computador (" " não é vazio), mas não tem nada. E é comum a pessoa digitar um espaço sem querer no começo ou no fim do e-mail. A função trim ("aparar") tira espaços, tabulações e quebras de linha das pontas do texto (o meio fica intacto):

<?php
var_dump(trim("  Ana Souza  "));
var_dump(trim("   "));
var_dump(trim("\tAna\n"));
$ php trim.php
string(9) "Ana Souza"       « o espaço do meio ficou
string(0) ""                « só espaços → vazio
string(3) "Ana"             « tab e quebra de linha também saem

Então, para um campo obrigatório, a receita é: ler com ?? '' (para não ter aviso se o campo nem veio), aparar com trim e comparar com o texto vazio:

$nome = trim($_POST['nome'] ?? '');
if ($nome === '') {
    $erros['nome'] = 'Informe o seu nome.';
}

Para limitar o tamanho, use mb_strlen (que conta letras acentuadas certo; veja Acentos e mbstring): mb_strlen($nome) > 60.


Passo 2: filter_var, o conferente pronto

Conferir se um texto "tem cara de e-mail" ou "é um número inteiro" à mão é difícil e cheio de casos estranhos. O PHP já traz uma função para isso: filter_var ("filtrar variável"). Você passa o valor e o filtro, e ela devolve:

  • o valor aprovado (já convertido para o tipo certo, no caso de números), ou
  • false, se o valor não passou.

E-mail: FILTER_VALIDATE_EMAIL

<?php
$testes = ['ana@exemplo.com', 'ana.souza@padaria.com.br', 'ana@', 'ana exemplo.com', 'ana@exemplo', '@exemplo.com', ' ana@exemplo.com'];
foreach ($testes as $t) {
    echo str_pad("'$t'", 30), " → ";
    var_dump(filter_var($t, FILTER_VALIDATE_EMAIL));
}
$ php email.php
'ana@exemplo.com'              → string(15) "ana@exemplo.com"
'ana.souza@padaria.com.br'     → string(24) "ana.souza@padaria.com.br"
'ana@'                         → bool(false)
'ana exemplo.com'              → bool(false)
'ana@exemplo'                  → bool(false)
'@exemplo.com'                 → bool(false)
' ana@exemplo.com'             → bool(false)       « o espaço na frente reprova: use trim antes
filter_var(
A função conferente.
$t,
O valor a conferir (aqui, cada texto da lista).
FILTER_VALIDATE_EMAIL)
Uma constante do PHP que escolhe o filtro: "valide como e-mail". Ela confere o formato (algo@domínio.algo), não se o e-mail existe. Para saber se existe, só mandando uma mensagem.

Inteiro: FILTER_VALIDATE_INT

<?php
$testes = ['29', '0', '-3', '29.5', 'vinte', '29 anos', ' 29 ', '', '150', '007'];
foreach ($testes as $t) {
    echo str_pad("'$t'", 10), " → ";
    var_dump(filter_var($t, FILTER_VALIDATE_INT));
}
$ php inteiro.php
'29'       → int(29)        « aprovado E convertido: agora é int, não string
'0'        → int(0)
'-3'       → int(-3)
'29.5'     → bool(false)    « tem vírgula decimal: não é inteiro
'vinte'    → bool(false)
'29 anos'  → bool(false)    « diferente do (int), que aceitaria o "29" do começo
' 29 '     → int(29)        « espaços nas pontas são tolerados neste filtro
''         → bool(false)
'150'      → int(150)
'007'      → bool(false)    « zero à esquerda não é aceito

Repare na vantagem sobre (int): (int) "29 anos" daria 29 sem reclamar, e (int) "vinte" daria 0. O filter_var diz claramente "isso não é um inteiro".

Com faixa: min_range e max_range

O terceiro argumento do filter_var recebe opções. Para o inteiro, as opções min_range e max_range definem o menor e o maior valor aceitos:

<?php
$opcoes = ['options' => ['min_range' => 1, 'max_range' => 120]];
foreach (['29', '0', '120', '150', 'vinte'] as $t) {
    echo str_pad("'$t'", 8), " → ";
    var_dump(filter_var($t, FILTER_VALIDATE_INT, $opcoes));
}
$ php faixa.php
'29'     → int(29)
'0'      → bool(false)      « abaixo do mínimo
'120'    → int(120)         « os limites valem (1 e 120 são aceitos)
'150'    → bool(false)      « acima do máximo
'vinte'  → bool(false)
['options' => [ … ]]
Um array com a chave 'options', e dentro dela outro array. Parece burocrático, mas é o formato que a função exige: escreva igualzinho.
'min_range' => 1
O menor valor aceito ("faixa mínima").
'max_range' => 120
O maior valor aceito.

A armadilha do zero: compare com === false

O filter_var devolve false para reprovar, mas também pode devolver 0 para aprovar o número zero. Num if, os dois "parecem falso" (veja Booleanos):

<?php
$qtd = filter_var('0', FILTER_VALIDATE_INT);
if (!$qtd) {
    echo "Errado: 0 foi tratado como inválido\n";
}
if ($qtd === false) {
    echo "Inválido\n";
} else {
    echo "Válido: $qtd\n";
}
$ php zero.php
Errado: 0 foi tratado como inválido
Válido: 0

Por isso: sempre === false para saber se o filter_var reprovou.

FiltroAprovaDevolve
FILTER_VALIDATE_EMAILFormato de e-mailo texto, ou false
FILTER_VALIDATE_INTInteiro (com min_range/max_range opcionais)int, ou false
FILTER_VALIDATE_FLOATNúmero com ponto decimal ("9.90")float, ou false
FILTER_VALIDATE_URLEndereço com esquema (https://…)o texto, ou false
FILTER_VALIDATE_BOOL"1", "true", "on", "yes" → true; "0", "false", "off", "no", "" e qualquer outro texto → falsebool

Passo 3: o formulário completo

Agora juntamos tudo num cadastro com nome, e-mail e idade. A estrutura é sempre a mesma, em três partes: ler e limpar, conferir (guardando cada problema num array $erros), decidir (sem erros, segue em frente; com erros, mostra o formulário de novo).

<?php
$dados = ['nome' => '', 'email' => '', 'idade' => ''];
$erros = [];
$sucesso = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1. Ler e limpar
    $dados['nome']  = trim($_POST['nome'] ?? '');
    $dados['email'] = trim($_POST['email'] ?? '');
    $dados['idade'] = trim($_POST['idade'] ?? '');

    // 2. Conferir cada campo
    if ($dados['nome'] === '') {
        $erros['nome'] = 'Informe o seu nome.';
    } elseif (mb_strlen($dados['nome']) > 60) {
        $erros['nome'] = 'O nome pode ter no máximo 60 letras.';
    }

    if ($dados['email'] === '') {
        $erros['email'] = 'Informe o seu e-mail.';
    } elseif (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
        $erros['email'] = 'Este e-mail não parece válido.';
    }

    $idade = filter_var($dados['idade'], FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1, 'max_range' => 120]]);
    if ($idade === false) {
        $erros['idade'] = 'A idade deve ser um número inteiro de 1 a 120.';
    }

    // 3. Decidir
    if (count($erros) === 0) {
        $sucesso = true;   // aqui você salvaria no banco, enviaria o e-mail…
    }
}
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Cadastro</title>
<style>.erro { color: #c00; } .ok { color: #070; }</style></head>
<body>
<?php if ($sucesso): ?>
  <p class="ok">Cadastro recebido, <?= htmlspecialchars($dados['nome']) ?>!</p>
<?php endif; ?>
<form method="post" novalidate>
  <p><label>Nome: <input name="nome" value="<?= htmlspecialchars($dados['nome']) ?>"></label>
    <?php if (isset($erros['nome'])): ?><span class="erro"><?= $erros['nome'] ?></span><?php endif; ?></p>
  <p><label>E-mail: <input type="email" name="email" value="<?= htmlspecialchars($dados['email']) ?>"></label>
    <?php if (isset($erros['email'])): ?><span class="erro"><?= $erros['email'] ?></span><?php endif; ?></p>
  <p><label>Idade: <input type="number" name="idade" value="<?= htmlspecialchars($dados['idade']) ?>"></label>
    <?php if (isset($erros['idade'])): ?><span class="erro"><?= $erros['idade'] ?></span><?php endif; ?></p>
  <button>Cadastrar</button>
</form>
</body>
</html>
TrechoPor quê
$dados = ['nome' => '', …]Começa tudo vazio. Na primeira visita (GET), o formulário usa esses valores e aparece em branco, sem avisos de chave inexistente.
$erros = []Um array associativo: a chave é o nome do campo, o valor é a mensagem. Assim cada mensagem sabe ao lado de qual campo aparecer.
if (… === 'POST')Só confere quando houve envio (de $_GET e $_POST). Na primeira visita, nenhuma mensagem de erro.
if … elseif …Para cada campo, uma mensagem só: se está vazio, nem adianta conferir o formato.
count($erros) === 0Nenhum problema: pode seguir (salvar, enviar). Num sistema de verdade, aqui viria um redirecionamento (header e Redirecionamento).
value="<?= htmlspecialchars($dados['nome']) ?>"Mantém o que a pessoa digitou. Se o e-mail estava errado, ela corrige só o e-mail, sem redigitar o resto. O htmlspecialchars é obrigatório aqui (veja Escapar a Saída).
isset($erros['nome'])Mostra o <span class="erro"> só se aquele campo tem erro.
novalidateDesliga a conferência do navegador neste formulário, para você ver a do PHP funcionando. Num site de verdade, pode deixar as duas.

As três situações

Primeira visita (GET): formulário vazio, nenhuma mensagem.

$ curl -s localhost:8000/cadastro.php
…
<form method="post" novalidate>
  <p><label>Nome: <input name="nome" value=""></label>
    </p>
  <p><label>E-mail: <input type="email" name="email" value=""></label>
    </p>
  <p><label>Idade: <input type="number" name="idade" value=""></label>
    </p>
  <button>Cadastrar</button>
</form>
…

Envio com problemas: nome só com espaços, e-mail sem o ".com", idade 150.

$ curl -s localhost:8000/cadastro.php -d "nome=+++&email=ana%40exemplo&idade=150"
…
<form method="post" novalidate>
  <p><label>Nome: <input name="nome" value=""></label>
    <span class="erro">Informe o seu nome.</span></p>
  <p><label>E-mail: <input type="email" name="email" value="ana@exemplo"></label>
    <span class="erro">Este e-mail não parece válido.</span></p>
  <p><label>Idade: <input type="number" name="idade" value="150"></label>
    <span class="erro">A idade deve ser um número inteiro de 1 a 120.</span></p>
  <button>Cadastrar</button>
</form>
…

Cada mensagem está ao lado do seu campo, e o e-mail e a idade voltaram preenchidos (value="ana@exemplo", value="150"). O nome voltou vazio porque o trim transformou os três espaços em nada.

Envio correto (com espaços sobrando no nome, que o trim resolve):

$ curl -s localhost:8000/cadastro.php -d "nome=++Ana+Souza+&email=ana%40exemplo.com&idade=29"
…
<body>
  <p class="ok">Cadastro recebido, Ana Souza!</p>
<form method="post" novalidate>
…

O cadastro completo, aberto com um envio cheio de problemas. Veja as mensagens na aba Resultado, corrija um campo de cada vez e envie de novo: as mensagens somem uma a uma, e o que você digitou continua lá. Experimente idade 0, 29.5, vinte.

<?php
$dados = ['nome' => '', 'email' => '', 'idade' => ''];
$erros = [];
$sucesso = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $dados['nome']  = trim($_POST['nome'] ?? '');
    $dados['email'] = trim($_POST['email'] ?? '');
    $dados['idade'] = trim($_POST['idade'] ?? '');

    if ($dados['nome'] === '') {
        $erros['nome'] = 'Informe o seu nome.';
    }
    if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
        $erros['email'] = 'Este e-mail não parece válido.';
    }
    $idade = filter_var($dados['idade'], FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1, 'max_range' => 120]]);
    if ($idade === false) {
        $erros['idade'] = 'A idade deve ser um número inteiro de 1 a 120.';
    }
    $sucesso = count($erros) === 0;
}
?>
<style>.erro { color: #c00; } .ok { color: #070; }</style>
<?php if ($sucesso): ?>
  <p class="ok">Cadastro recebido, <?= htmlspecialchars($dados['nome']) ?>!</p>
<?php endif; ?>
<form method="post" novalidate>
  <p>Nome: <input name="nome" value="<?= htmlspecialchars($dados['nome']) ?>">
    <span class="erro"><?= $erros['nome'] ?? '' ?></span></p>
  <p>E-mail: <input name="email" value="<?= htmlspecialchars($dados['email']) ?>">
    <span class="erro"><?= $erros['email'] ?? '' ?></span></p>
  <p>Idade: <input name="idade" value="<?= htmlspecialchars($dados['idade']) ?>">
    <span class="erro"><?= $erros['idade'] ?? '' ?></span></p>
  <button>Cadastrar</button>
</form>

Repare num atalho que a caixa acima usa: <?= $erros['nome'] ?? '' ?> escreve a mensagem se existir, ou nada. É uma alternativa mais curta ao if (isset(…)); o <span> vazio não aparece na tela.


Formas diferentes de organizar

Uma função para ler campos com segurança

Quando o formulário tem muitos campos, repetir trim($_POST['x'] ?? '') cansa. E tem um furo: se alguém mandar um array no lugar do texto (idade[]=29), o trim para o programa (veja o cenário em Quando dá errado). Uma função pequena resolve as duas coisas (funções em Funções):

function campo(string $nome): string
{
    $valor = $_POST[$nome] ?? '';
    return is_string($valor) ? trim($valor) : '';
}

$dados['nome']  = campo('nome');
$dados['email'] = campo('email');

Lista de opções permitidas

Para campos de escolha (<select>, radio), confira se o valor está na lista do que você ofereceu. Alguém pode mandar sabor=pizza mesmo que o <select> só tenha bolos:

$sabores = ['cenoura', 'chocolate', 'limao'];
$sabor = campo('sabor');
if (!in_array($sabor, $sabores, true)) {
    $erros['sabor'] = 'Escolha um sabor da lista.';
}

O true no fim do in_array faz a comparação ser estrita (===), como você viu em Funções de Array.

Erros todos no topo, ou ao lado de cada campo?

FormaComoQuando usar
Ao lado do campo$erros['email'] junto do <input name="email">O melhor para o visitante: ele vê exatamente o que corrigir. Use sempre que puder.
Lista no topoforeach ($erros as $msg) { echo "<li>$msg</li>"; }Formulários curtos, ou em conjunto com o anterior (um resumo no topo e o detalhe ao lado).

Validar no cliente × no servidor

No cliente (navegador)No servidor (PHP)
Comorequired, type="email", min/max, maxlength, pattern, JavaScripttrim, filter_var, in_array, mb_strlen, if
Quando aconteceAntes de enviar; resposta instantâneaDepois do envio; precisa ir e voltar do servidor
Pode ser pulada?Sim: curl, F12, JavaScript desligado, outro programaNão: todo pedido passa pelo PHP
Serve paraConforto: avisar cedo, sem recarregar a páginaSegurança e dados corretos no sistema
Obrigatória?Não (mas é gentil)Sim, sempre

O ideal é ter as duas: o navegador avisa rápido quem errou sem querer, e o PHP barra o resto. Se for escolher uma só, é a do servidor.


Quando dá errado

trim(): Argument #1 ($string) must be of type string, array given
$ curl -s localhost:8000/cadastro.php -d "nome=Ana&email=ana%40exemplo.com&idade%5B%5D=29"
<br />
<b>Fatal error</b>:  Uncaught TypeError: trim(): Argument #1 ($string) must be of type string, array given in /home/ana/site/cadastro.php:10
Stack trace:
#0 /home/ana/site/cadastro.php(10): trim(Array)
#1 {main}
  thrown in <b>/home/ana/site/cadastro.php</b> on line <b>10</b><br />

O que aconteceu: alguém mandou idade[]=29 (com colchetes), e o PHP transformou a idade em array (como em name="frutas[]"). O trim só aceita texto e o programa parou com um erro fatal (Uncaught TypeError = erro de tipo não tratado). O seu formulário nunca mandaria isso; um atacante ou um robô, sim.

Como resolver: confira o tipo antes, com a função campo() mostrada acima (is_string($valor) ? trim($valor) : '').

A quantidade 0 foi recusada (ou aceita) sem querer
$ php zero.php
Errado: 0 foi tratado como inválido
Válido: 0

O que aconteceu: if (!filter_var(…)) trata o 0 aprovado como se fosse false. O mesmo acontece com empty($_POST['qtd']), que acha "0" vazio.

Como resolver: if ($qtd === false). Se o zero não for permitido, diga isso com min_range, não por acidente.

Um e-mail válido foi recusado

O que aconteceu: quase sempre, espaço nas pontas (' ana@exemplo.com' → bool(false), como na tabela acima). Celulares adoram colocar um espaço depois do e-mail.

Como resolver: trim antes do filter_var. E guarde o valor já aparado.

Depois do erro, o formulário voltou em branco

O que aconteceu: os <input> não têm value, então o navegador mostra o formulário vazio. A pessoa precisa digitar tudo de novo, e muitas desistem.

Como resolver: value="<?= htmlspecialchars($dados['campo']) ?>" em cada campo. Para <textarea>, o valor vai entre as tags; para <select>, coloque selected na opção que bate (<?= $dados['sabor'] === 'limao' ? 'selected' : '' ?>); para checkbox e radio, checked. Nunca devolva o campo de senha preenchido.

O campo voltou "cortado" quando tinha aspas

O que aconteceu: o valor foi escrito no value="…" sem htmlspecialchars. Um nome como Ana "Aninha" Souza fecha as aspas do atributo no meio, e o resto some (ou vira HTML). Esse é o começo de um ataque XSS.

Como resolver: sempre htmlspecialchars ao devolver dados para a página. A aula seguinte, Escapar a Saída (XSS), é inteira sobre isso.


Referência

CódigoO que faz
trim($s)Tira espaços, tabs e quebras de linha das pontas
$s === ''Campo obrigatório vazio (depois do trim)
mb_strlen($s) > 60Texto longo demais (contando acentos certo)
filter_var($s, FILTER_VALIDATE_EMAIL)O e-mail, ou false
filter_var($s, FILTER_VALIDATE_INT)O int, ou false
filter_var($s, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10]])Inteiro de 1 a 10, ou false
… === falseA forma certa de saber se o filtro reprovou
in_array($v, $permitidos, true)O valor está na lista de opções?
is_string($v)Chegou texto (e não um array)?
$erros['campo'] = 'mensagem'Guarda o erro de um campo
value="<?= htmlspecialchars($v) ?>"Devolve ao campo o que foi digitado

Exercícios

1. Obrigatório de verdade

Complete para que um nome feito só de espaços seja considerado vazio:

$nome = ($_POST['nome'] ?? '');

2. O filtro de e-mail

Complete com a constante do filtro:

filter_var($email, )

3. Reprovou?

Complete a comparação que não confunde o número zero com reprovação:

if ($qtd ) { … }

4. O formulário tem <input type="number" min="1" max="10" required>. O PHP ainda precisa conferir o valor?

5. O que filter_var('29 anos', FILTER_VALIDATE_INT) devolve?

6. Por que guardar os erros como $erros['email'] = '…', com o nome do campo na chave?

Missão: a encomenda da padaria aceita de 1 a 10 bolos. A página foi aberta com um envio de 15. Complete a conferência da quantidade com filter_var e FILTER_VALIDATE_INT com min_range 1 e max_range 10; se reprovar, guarde em $erros['qtd'] a mensagem Escolha de 1 a 10 bolos. Depois teste na aba Resultado com 0, 3, 10 e "dois".

<?php
$cliente = '';
$qtd = '';
$erros = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $cliente = trim($_POST['cliente'] ?? '');
    $qtd = trim($_POST['qtd'] ?? '');
    if ($cliente === '') {
        $erros['cliente'] = 'Informe o nome.';
    }
    // confira a quantidade aqui

    if (count($erros) === 0) {
        echo "<p>Encomenda de $qtd bolo(s) para " . htmlspecialchars($cliente) . " anotada!</p>";
    }
}
?>
<form method="post" novalidate>
  <p>Nome: <input name="cliente" value="<?= htmlspecialchars($cliente) ?>">
     <?= $erros['cliente'] ?? '' ?></p>
  <p>Quantidade: <input name="qtd" value="<?= htmlspecialchars($qtd) ?>">
     <?= $erros['qtd'] ?? '' ?></p>
  <button>Encomendar</button>
</form>
<?php
$cliente = '';
$qtd = '';
$erros = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $cliente = trim($_POST['cliente'] ?? '');
    $qtd = trim($_POST['qtd'] ?? '');
    if ($cliente === '') {
        $erros['cliente'] = 'Informe o nome.';
    }
    $n = filter_var($qtd, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10]]);
    if ($n === false) {
        $erros['qtd'] = 'Escolha de 1 a 10 bolos.';
    }

    if (count($erros) === 0) {
        echo "<p>Encomenda de $qtd bolo(s) para " . htmlspecialchars($cliente) . " anotada!</p>";
    }
}
?>
<form method="post" novalidate>
  <p>Nome: <input name="cliente" value="<?= htmlspecialchars($cliente) ?>">
     <?= $erros['cliente'] ?? '' ?></p>
  <p>Quantidade: <input name="qtd" value="<?= htmlspecialchars($qtd) ?>">
     <?= $erros['qtd'] ?? '' ?></p>
  <button>Encomendar</button>
</form>

Missão: o formulário mostra o erro, mas apaga o que a pessoa digitou. A página foi aberta com um envio (nome certo, e-mail errado). Faça os dois campos voltarem preenchidos, com value="<?= htmlspecialchars(…) ?>".

<?php
$nome = trim($_POST['nome'] ?? '');
$email = trim($_POST['email'] ?? '');
$erros = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST'
    && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    $erros['email'] = 'Este e-mail não parece válido.';
}
?>
<form method="post">
  <p>Nome: <input name="nome"></p>
  <p>E-mail: <input name="email"> <?= $erros['email'] ?? '' ?></p>
  <button>Enviar</button>
</form>
<?php
$nome = trim($_POST['nome'] ?? '');
$email = trim($_POST['email'] ?? '');
$erros = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST'
    && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
    $erros['email'] = 'Este e-mail não parece válido.';
}
?>
<form method="post">
  <p>Nome: <input name="nome" value="<?= htmlspecialchars($nome) ?>"></p>
  <p>E-mail: <input name="email" value="<?= htmlspecialchars($email) ?>"> <?= $erros['email'] ?? '' ?></p>
  <button>Enviar</button>
</form>

Resumo

  • A validação do navegador é conforto; a do PHP é obrigatória. Tudo o que vem do visitante é suspeito até ser conferido.
  • Leia com ?? '', apare com trim, compare com === '' para campos obrigatórios.
  • filter_var devolve o valor aprovado ou false: FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_INT (com min_range/max_range).
  • Sempre === false: o zero aprovado parece falso num if.
  • Guarde os problemas em $erros['campo']; sem erros (count($erros) === 0), siga em frente.
  • Mostre cada erro ao lado do campo e devolva os valores digitados com value="<?= htmlspecialchars(…) ?>".
  • Para escolhas, confira com in_array(…, true); para texto, garanta que não chegou um array (is_string).