Formulários são a porta de entrada dos dados do usuário para o servidor. Tudo que um sistema web faz de significativo — login, cadastro, busca, compra, envio de mensagem — passa por um formulário. Saber processar formulários com segurança e corretamente é uma das habilidades mais fundamentais do desenvolvimento web com PHP.
Neste artigo cobrimos o ciclo completo: como o navegador envia dados, como o PHP os recebe, como validar com rigor e como dar feedback útil ao usuário — tudo sem framework, para que você entenda o que acontece por baixo.
Como os dados chegam ao PHP
Quando um usuário submete um formulário HTML, o navegador empacota os dados e os envia para o servidor via HTTP. O método do envio determina como os dados chegam:
GET — os dados vão na URL, como query string: ?nome=Alice&idade=30. Visíveis, limitados em tamanho (~2000 caracteres), não adequados para dados sensíveis. Use para buscas e filtros.
POST — os dados vão no corpo da requisição HTTP, invisíveis na URL. Sem limite prático de tamanho (configurável). Use para envio de dados, login, cadastro.
No PHP, os dados chegam em superglobais:
<?php
// Dados enviados por GET (query string)
$nome = $_GET['nome'] ?? '';
// Dados enviados por POST (corpo da requisição)
$email = $_POST['email'] ?? '';
// $_REQUEST contém GET + POST + COOKIE — evite, é ambíguo
Formulário simples — ciclo completo
<?php
declare(strict_types=1);
// O mesmo arquivo processa o formulário e o exibe
// Técnica "Post/Redirect/Get" — veremos adiante
$erros = [];
$sucesso = false;
$dados = ['nome' => '', 'email' => '', 'mensagem' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Lê os dados com trim() para remover espaços acidentais
$dados['nome'] = trim($_POST['nome'] ?? '');
$dados['email'] = trim($_POST['email'] ?? '');
$dados['mensagem'] = trim($_POST['mensagem'] ?? '');
// Validação
if ($dados['nome'] === '') {
$erros['nome'] = 'O nome é obrigatório.';
// mb_strlen, e não strlen: o segundo conta BYTES. Em português isso
// muda o resultado — cada acento ocupa dois bytes em UTF-8, então um
// nome de 95 caracteres com dez acentos passa dos 100 bytes e seria
// recusado por um limite que ele nunca alcançou.
} elseif (mb_strlen($dados['nome']) < 2) {
$erros['nome'] = 'O nome deve ter pelo menos 2 caracteres.';
} elseif (mb_strlen($dados['nome']) > 100) {
$erros['nome'] = 'O nome deve ter no máximo 100 caracteres.';
}
if ($dados['email'] === '') {
$erros['email'] = 'O email é obrigatório.';
} elseif (!filter_var($dados['email'], FILTER_VALIDATE_EMAIL)) {
$erros['email'] = 'Informe um email válido.';
}
if ($dados['mensagem'] === '') {
$erros['mensagem'] = 'A mensagem é obrigatória.';
} elseif (strlen($dados['mensagem']) < 10) {
$erros['mensagem'] = 'A mensagem deve ter pelo menos 10 caracteres.';
}
// Se não houver erros, processa
if (empty($erros)) {
// Aqui: salvar no banco, enviar email, etc.
$sucesso = true;
// Boa prática: redirecionar após sucesso (Post/Redirect/Get)
// Evita reenvio do formulário ao pressionar F5
header('Location: contato.php?enviado=1');
exit;
}
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Formulário de Contato</title>
</head>
<body>
<?php if (isset($_GET['enviado'])): ?>
<p style="color: green">Mensagem enviada com sucesso!</p>
<?php endif; ?>
<form method="POST" action="contato.php" novalidate>
<div>
<label for="nome">Nome:</label>
<!-- htmlspecialchars evita XSS ao repopular o campo -->
<input type="text" id="nome" name="nome"
value="<?= htmlspecialchars($dados['nome']) ?>"
maxlength="100">
<?php if (isset($erros['nome'])): ?>
<span class="erro"><?= htmlspecialchars($erros['nome']) ?></span>
<?php endif; ?>
</div>
<div>
<label for="email">Email:</label>
<input type="email" id="email" name="email"
value="<?= htmlspecialchars($dados['email']) ?>">
<?php if (isset($erros['email'])): ?>
<span class="erro"><?= htmlspecialchars($erros['email']) ?></span>
<?php endif; ?>
</div>
<div>
<label for="mensagem">Mensagem:</label>
<textarea id="mensagem" name="mensagem" rows="5"><?=
htmlspecialchars($dados['mensagem'])
?></textarea>
<?php if (isset($erros['mensagem'])): ?>
<span class="erro"><?= htmlspecialchars($erros['mensagem']) ?></span>
<?php endif; ?>
</div>
<button type="submit">Enviar</button>
</form>
</body>
</html>
Validação robusta com filter_var e filter_input
O PHP oferece funções nativas para validação e sanitização de entrada:
<?php
// filter_var — valida ou sanitiza um valor
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
// Email inválido
}
// FILTER_VALIDATE_INT — valida inteiro com faixa opcional
$idade = filter_var($_POST['idade'] ?? '', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 150]
]);
// FILTER_VALIDATE_FLOAT.
// FILTER_FLAG_ALLOW_FRACTION pertence ao SANITIZE_NUMBER_FLOAT e aqui é
// simplesmente ignorada — o resultado é o mesmo com ela e sem ela.
// O que o VALIDATE_FLOAT entende é FILTER_FLAG_ALLOW_THOUSAND e a opção
// 'decimal'. Sem declarar o separador, o esperado é o ponto e um "9,90"
// vindo do formulário devolve false. Com as duas linhas abaixo, "9,90" e
// "1.234,56" passam a ser aceitos como o usuário brasileiro os digita — e,
// em troca, "9.90" passa a ser recusado. Escolha conforme a origem do dado.
$preco = filter_var($_POST['preco'] ?? '', FILTER_VALIDATE_FLOAT, [
'flags' => FILTER_FLAG_ALLOW_THOUSAND,
'options' => ['decimal' => ','],
]);
// FILTER_VALIDATE_URL
$site = filter_var($_POST['site'] ?? '', FILTER_VALIDATE_URL);
// FILTER_SANITIZE_NUMBER_INT — remove tudo que não é dígito ou sinal
$telefone = filter_var($_POST['telefone'] ?? '', FILTER_SANITIZE_NUMBER_INT);
// filter_input — lê e filtra diretamente de $_POST, $_GET etc.
$nome = filter_input(INPUT_POST, 'nome', FILTER_SANITIZE_SPECIAL_CHARS);
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
Classe de validação reutilizável
Em vez de repetir validações espalhadas, crie uma classe:
<?php
declare(strict_types=1);
class Validador
{
private array $erros = [];
// O primeiro erro de cada campo é o que vale. Sem esta guarda, a cadeia
// obrigatorio()->tamanho() faz o segundo sobrescrever o primeiro: um campo
// deixado em branco acusaria "deve ter pelo menos 2 caracteres" no lugar
// de "é obrigatório", que é a informação de que o usuário precisa.
private function registrar(string $campo, string $mensagem): void
{
$this->erros[$campo] ??= $mensagem;
}
public function obrigatorio(string $campo, string $valor, string $rotulo): static
{
if (trim($valor) === '') {
$this->registrar($campo, "{$rotulo} é obrigatório.");
}
return $this;
}
public function tamanho(string $campo, string $valor, int $min, int $max, string $rotulo): static
{
$len = mb_strlen(trim($valor));
if ($len < $min) {
$this->registrar($campo, "{$rotulo} deve ter pelo menos {$min} caracteres.");
} elseif ($len > $max) {
$this->registrar($campo, "{$rotulo} deve ter no máximo {$max} caracteres.");
}
return $this;
}
public function email(string $campo, string $valor): static
{
if (!filter_var(trim($valor), FILTER_VALIDATE_EMAIL)) {
$this->registrar($campo, 'Informe um email válido.');
}
return $this;
}
public function inteiro(string $campo, mixed $valor, string $rotulo, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): static
{
$v = filter_var($valor, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]);
if ($v === false) {
$this->registrar($campo, "{$rotulo} deve ser um número inteiro entre {$min} e {$max}.");
}
return $this;
}
public function regex(string $campo, string $valor, string $padrao, string $mensagem): static
{
if (!preg_match($padrao, $valor)) {
$this->registrar($campo, $mensagem);
}
return $this;
}
public function valido(): bool { return empty($this->erros); }
public function erros(): array { return $this->erros; }
public function erro(string $campo): ?string { return $this->erros[$campo] ?? null; }
}
// Uso:
$v = new Validador();
$v->obrigatorio('nome', $_POST['nome'] ?? '', 'Nome')
->tamanho('nome', $_POST['nome'] ?? '', 2, 100, 'Nome')
->obrigatorio('email', $_POST['email'] ?? '', 'Email')
->email('email', $_POST['email'] ?? '')
->inteiro('idade', $_POST['idade'] ?? '', 'Idade', 1, 150);
if ($v->valido()) {
// processa
} else {
$erros = $v->erros();
}
Checkboxes, radios e selects
<?php
// Checkbox: presente no POST se marcado, ausente se desmarcado
$aceita_termos = isset($_POST['termos']) && $_POST['termos'] === '1';
// Radio: só um valor possível
$plano = $_POST['plano'] ?? '';
$planosValidos = ['basico', 'pro', 'enterprise'];
if (!in_array($plano, $planosValidos, strict: true)) {
$erros['plano'] = 'Selecione um plano válido.';
}
// Select múltiplo: array de valores
$interesses = $_POST['interesses'] ?? [];
if (!is_array($interesses)) {
$interesses = [];
}
// Sanitiza cada valor do array
$interesses = array_filter($interesses, fn($i) => in_array($i, ['php', 'js', 'python'], true));
// No HTML: name="interesses[]" para enviar como array
<input type="checkbox" name="termos" value="1"> Li e aceito os termos
<input type="radio" name="plano" value="basico"> Básico
<input type="radio" name="plano" value="pro"> Pro
<select name="interesses[]" multiple>
<option value="php">PHP</option>
<option value="js">JavaScript</option>
<option value="python">Python</option>
</select>
Padrão Post/Redirect/Get
O maior erro de iniciantes em formulários PHP é não usar o padrão PRG. Sem ele, ao pressionar F5 após enviar um formulário, o navegador pergunta "reenviar dados?" — e se o usuário confirmar, o formulário é enviado novamente, criando duplicatas.
A solução é simples: após processar com sucesso, redirecione. Nunca retorne uma resposta HTML diretamente de um POST:
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// valida e processa...
if ($sucesso) {
// Redireciona — o próximo acesso será GET
// Pode passar mensagem via session ou query string
$_SESSION['flash'] = 'Cadastro realizado com sucesso!';
header('Location: /usuarios/lista');
exit; // SEMPRE após header Location
}
}
Processar formulário é menos sobre sintaxe e mais sobre desconfiança organizada. O required e o pattern do HTML servem ao usuário, não ao servidor: qualquer pessoa manda um POST direto, sem passar pelo navegador, e o que chega precisa ser validado como se viesse de um desconhecido — porque vem. Daí a regra de repopular os campos com htmlspecialchars e de o servidor refazer, sozinho, toda checagem que a tela já fez.
Repare que a ordem das mensagens importa tanto quanto a validação em si. Um validador encadeado que sobrescreve o erro anterior acaba dizendo ao usuário que o campo em branco "precisa de dois caracteres" — tecnicamente verdade, praticamente inútil. E, antes de tudo isso, o próprio PHP já alterou o que chegou: nome com ponto vira nome com sublinhado, e formulário muito grande perde campos em silêncio.
Fontes e leituras recomendadas
- PHP Manual — Variáveis de formulários: https://www.php.net/manual/pt_BR/tutorial.forms.php
- PHP Manual — filter_var: https://www.php.net/manual/pt_BR/function.filter-var.php
- OWASP — Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- PHP Manual —
$_POSTe o que chega (ou nao) conforme oenctype: https://www.php.net/manual/pt_BR/reserved.variables.post.php - OWASP — prevencao de CSRF, o token que todo formulario de mutacao precisa: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- MDN — validacao de formulario no navegador, que complementa mas nao substitui a do servidor: https://developer.mozilla.org/pt-BR/docs/Learn_web_development/Extensions/Forms/Form_validation
Exercícios
Exercício 1
Crie um formulário de cadastro com: nome (2–100 chars), email (válido, único no banco), senha (mínimo 8 chars, pelo menos uma letra maiúscula e um número), data de nascimento (deve ser maior de 18 anos), plano (radio: básico, pro), interesses (checkbox múltiplo).
Ver resposta
✓ Resposta: Dois detalhes que passam batido: mb_strlen em vez de strlen (senão "José" conta 5 caracteres) e o ! no createFromFormat junto com getLastErrors() — sem isso, 2026-02-31 é aceito e vira 3 de março silenciosamente.
<?php
declare(strict_types=1);
final class ValidadorCadastro
{
public function __construct(private readonly PDO $pdo) {}
/** @return array<string, string> campo => mensagem */
public function validar(array $dados): array
{
$erros = [];
// ---- nome: 2 a 100 caracteres ----
$nome = trim((string) ($dados['nome'] ?? ''));
// mb_strlen, não strlen: "José" tem 4 letras e 5 bytes.
if (mb_strlen($nome) < 2 || mb_strlen($nome) > 100) {
$erros['nome'] = 'O nome precisa ter entre 2 e 100 caracteres.';
}
// ---- e-mail: formato + unicidade ----
$email = mb_strtolower(trim((string) ($dados['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$erros['email'] = 'Informe um e-mail válido.';
} elseif ($this->emailJaExiste($email)) {
$erros['email'] = 'Este e-mail já está cadastrado.';
}
// ---- senha: 8+, uma maiúscula, um número ----
$senha = (string) ($dados['senha'] ?? '');
if (mb_strlen($senha) < 8) {
$erros['senha'] = 'A senha precisa de ao menos 8 caracteres.';
} elseif (!preg_match('/[A-ZÀ-Þ]/u', $senha)) {
$erros['senha'] = 'A senha precisa de ao menos uma letra maiúscula.';
} elseif (!preg_match('/\d/', $senha)) {
$erros['senha'] = 'A senha precisa de ao menos um número.';
} elseif (isset($dados['senha_confirmacao'])
&& !hash_equals($senha, (string) $dados['senha_confirmacao'])) {
$erros['senha_confirmacao'] = 'As senhas não conferem.';
}
// ---- nascimento: maior de 18 ----
$nascimento = (string) ($dados['nascimento'] ?? '');
$data = \DateTimeImmutable::createFromFormat('!Y-m-d', $nascimento);
// O `!` no formato zera hora/minuto/segundo; o getLastErrors pega
// datas como 2026-02-31, que o createFromFormat aceitaria rolando
// para março.
if ($data === false || \DateTimeImmutable::getLastErrors()['warning_count'] > 0) {
$erros['nascimento'] = 'Informe uma data válida.';
} else {
$idade = $data->diff(new \DateTimeImmutable('today'))->y;
if ($data > new \DateTimeImmutable('today')) {
$erros['nascimento'] = 'A data não pode estar no futuro.';
} elseif ($idade < 18) {
$erros['nascimento'] = 'É preciso ter 18 anos ou mais.';
} elseif ($idade > 120) {
$erros['nascimento'] = 'Confira a data de nascimento.';
}
}
// ---- plano: lista branca ----
if (!in_array($dados['plano'] ?? '', ['basico', 'pro'], true)) {
$erros['plano'] = 'Escolha um plano.';
}
// ---- interesses: checkbox múltiplo ----
$interesses = $dados['interesses'] ?? [];
$validos = ['tecnologia', 'design', 'negocios', 'marketing'];
if (!is_array($interesses) || $interesses === []) {
$erros['interesses'] = 'Escolha ao menos um interesse.';
} elseif (array_diff($interesses, $validos) !== []) {
// Valor fora da lista = campo adulterado no cliente.
$erros['interesses'] = 'Interesse inválido.';
}
return $erros;
}
private function emailJaExiste(string $email): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM usuarios WHERE email = :e LIMIT 1');
$stmt->execute([':e' => $email]);
return $stmt->fetchColumn() !== false;
}
}
Exercício 2
Implemente o padrão PRG: após cadastro com sucesso, redirecione para uma página de confirmação com mensagem flash via $_SESSION.
Ver resposta
✓ Resposta: Use 303, não 302: o 303 obriga o navegador a trocar para GET, enquanto no 302 alguns clientes mantêm o POST. E exit depois de header() é obrigatório — o PHP não para sozinho, e o código seguinte continuaria rodando.
<?php
declare(strict_types=1);
final class Flash
{
private const CHAVE = '_flash';
public static function definir(string $tipo, string $mensagem): void
{
$_SESSION[self::CHAVE][] = ['tipo' => $tipo, 'mensagem' => $mensagem];
}
public static function sucesso(string $m): void { self::definir('sucesso', $m); }
public static function erro(string $m): void { self::definir('erro', $m); }
/** Lê E APAGA: por isso "flash" — aparece uma vez só. */
public static function consumir(): array
{
$mensagens = $_SESSION[self::CHAVE] ?? [];
unset($_SESSION[self::CHAVE]);
return $mensagens;
}
}
// ---------------------------------------- public/cadastro.php
declare(strict_types=1);
session_start();
require __DIR__ . '/../vendor/autoload.php';
// Só POST processa; GET apenas exibe.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$erros = $_SESSION['_erros'] ?? [];
$antigos = $_SESSION['_antigos'] ?? [];
unset($_SESSION['_erros'], $_SESSION['_antigos']);
require __DIR__ . '/../views/cadastro.php';
exit;
}
$erros = (new ValidadorCadastro($pdo))->validar($_POST);
if ($erros !== []) {
// Mesmo no erro, redireciona: assim o F5 não reenvia o POST.
$_SESSION['_erros'] = $erros;
// Devolve o que foi digitado, MENOS a senha.
$_SESSION['_antigos'] = array_diff_key($_POST, array_flip(['senha', 'senha_confirmacao']));
header('Location: /cadastro.php', true, 303);
exit;
}
$stmt = $pdo->prepare(
'INSERT INTO usuarios (nome, email, senha_hash, nascimento, plano, interesses)
VALUES (:nome, :email, :senha, :nasc, :plano, :interesses)'
);
$stmt->execute([
':nome' => trim($_POST['nome']),
':email' => mb_strtolower(trim($_POST['email'])),
':senha' => password_hash($_POST['senha'], PASSWORD_DEFAULT),
':nasc' => $_POST['nascimento'],
':plano' => $_POST['plano'],
':interesses' => json_encode($_POST['interesses'], JSON_UNESCAPED_UNICODE),
]);
Flash::sucesso('Cadastro realizado! Confira seu e-mail para confirmar a conta.');
/**
* O P-R-G: Post → Redirect → Get.
*
* Sem o redirect, a página de sucesso é a RESPOSTA do POST — e um F5 do
* usuário reenvia o formulário, criando um cadastro duplicado. Com o 303,
* o navegador troca para GET e o refresh apenas recarrega a confirmação.
*
* 303 (See Other) é o código correto aqui: o 302 deixa a critério do
* navegador manter o método, e alguns mantêm o POST.
*/
header('Location: /cadastro-confirmado.php', true, 303);
exit; // sempre exit depois de header(): o PHP continuaria executando
// ---------------------------------------- public/cadastro-confirmado.php
declare(strict_types=1);
session_start();
$mensagens = Flash::consumir();
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Cadastro confirmado</title></head>
<body>
<?php foreach ($mensagens as $flash): ?>
<div class="flash flash--<?= htmlspecialchars($flash['tipo'], ENT_QUOTES) ?>">
<?= htmlspecialchars($flash['mensagem'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endforeach; ?>
<h1>Tudo certo!</h1>
<p><a href="/login.php">Entrar na sua conta</a></p>
</body>
</html>
Exercício 3
Adicione validação do lado do cliente (HTML5 required, pattern, min/max) e garanta que o servidor revalide tudo independentemente — nunca confie apenas na validação do cliente.
Ver resposta
✓ Resposta: A regra é simples e não tem exceção: validação no cliente é conveniência, validação no servidor é segurança. O curl do comentário existe para deixar isso concreto — e repare que plano e interesses precisam de lista branca, ainda que na tela sejam radio e checkbox.
<?php
// views/cadastro.php — os atributos HTML5 melhoram a experiência,
// mas NÃO são validação: qualquer um remove com o DevTools ou manda
// a requisição direto com curl.
?>
<form method="post" action="/cadastro.php" novalidate>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf, ENT_QUOTES) ?>">
<label>Nome
<input type="text" name="nome" required minlength="2" maxlength="100"
value="<?= htmlspecialchars($antigos['nome'] ?? '', ENT_QUOTES) ?>"
aria-describedby="erro-nome">
</label>
<?php if (isset($erros['nome'])): ?>
<p id="erro-nome" class="erro"><?= htmlspecialchars($erros['nome'], ENT_QUOTES) ?></p>
<?php endif; ?>
<label>E-mail
<input type="email" name="email" required maxlength="180"
autocomplete="email"
value="<?= htmlspecialchars($antigos['email'] ?? '', ENT_QUOTES) ?>">
</label>
<label>Senha
<!-- O pattern é uma DICA. A regra de verdade está no PHP. -->
<input type="password" name="senha" required minlength="8"
pattern="(?=.*[A-Z])(?=.*\d).{8,}"
autocomplete="new-password"
title="Mínimo de 8 caracteres, com uma maiúscula e um número">
</label>
<label>Data de nascimento
<input type="date" name="nascimento" required
max="<?= (new DateTimeImmutable('-18 years'))->format('Y-m-d') ?>"
value="<?= htmlspecialchars($antigos['nascimento'] ?? '', ENT_QUOTES) ?>">
</label>
<fieldset>
<legend>Plano</legend>
<?php foreach (['basico' => 'Básico', 'pro' => 'Pro'] as $valor => $rotulo): ?>
<label>
<input type="radio" name="plano" value="<?= $valor ?>" required
<?= ($antigos['plano'] ?? '') === $valor ? 'checked' : '' ?>>
<?= $rotulo ?>
</label>
<?php endforeach; ?>
</fieldset>
<fieldset>
<legend>Interesses</legend>
<?php foreach (['tecnologia' => 'Tecnologia', 'design' => 'Design',
'negocios' => 'Negócios', 'marketing' => 'Marketing'] as $v => $r): ?>
<label>
<!-- Os colchetes fazem o PHP receber um array -->
<input type="checkbox" name="interesses[]" value="<?= $v ?>"
<?= in_array($v, $antigos['interesses'] ?? [], true) ? 'checked' : '' ?>>
<?= $r ?>
</label>
<?php endforeach; ?>
</fieldset>
<button type="submit">Criar conta</button>
</form>
<?php
// (o strict_types não entra aqui: numa view que já emitiu HTML, ele seria
// erro fatal — `declare` tem de ser a primeira instrução do arquivo.
// Este bloco pertence ao controller, em arquivo próprio.)
/**
* A DEMONSTRAÇÃO de por que o servidor revalida tudo.
*
* Qualquer uma destas requisições ignora 100% da validação HTML5 —
* e chega no PHP exatamente como uma vinda do formulário:
*
* curl -X POST https://site.com/cadastro.php \
* -d "nome=x" -d "email=nao-eh-email" -d "senha=123" \
* -d "nascimento=2020-01-01" -d "plano=gratuito_hackeado" \
* -d "interesses[]=<script>alert(1)</script>"
*
* O `plano` fora da lista branca e o interesse com script mostram por que
* não basta validar formato: valor de radio e checkbox É entrada do
* usuário, mesmo tendo vindo de uma lista fechada na tela.
*/
final class ProcessadorCadastro
{
public function __construct(
private readonly ValidadorCadastro $validador,
private readonly PDO $pdo,
) {}
public function processar(array $post, string $csrfEsperado): array
{
// 1) CSRF antes de tudo.
if (!hash_equals($csrfEsperado, (string) ($post['csrf'] ?? ''))) {
return ['erros' => ['_' => 'Sessão expirada. Recarregue a página.']];
}
// 2) A MESMA validação, independente do que o cliente disse.
$erros = $this->validador->validar($post);
if ($erros !== []) {
return ['erros' => $erros];
}
// 3) Só aqui os dados são considerados confiáveis.
return ['id' => $this->inserir($post)];
}
private function inserir(array $post): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO usuarios (nome, email, senha_hash, nascimento, plano, interesses)
VALUES (:nome, :email, :senha, :nasc, :plano, :int)'
);
$stmt->execute([
':nome' => trim($post['nome']),
':email' => mb_strtolower(trim($post['email'])),
':senha' => password_hash($post['senha'], PASSWORD_DEFAULT),
':nasc' => $post['nascimento'],
':plano' => $post['plano'],
':int' => json_encode($post['interesses'], JSON_UNESCAPED_UNICODE),
]);
return (int) $this->pdo->lastInsertId();
}
}
Exercício 4
Desafio: implemente rate limiting simples: armazene no banco (ou em arquivo) o IP e timestamp dos últimos envios. Se o mesmo IP enviar mais de 3 formulários em 5 minutos, retorne erro 429.
Ver resposta
✓ Resposta: Duas decisões que fazem diferença: registrar a tentativa antes de processar (senão envios inválidos não contam e o limite não protege de nada) e guardar o IP com hash_hmac — endereço IP é dado pessoal, e o hash permite contar sem armazená-lo em claro.
<?php
declare(strict_types=1);
interface LimitadorEnvioInterface
{
/** @return array{permitido: bool, restantes: int, retry_after: int} */
public function verificar(string $ip): array;
public function registrar(string $ip): void;
}
/** Versão em banco: funciona com múltiplos servidores. */
final class LimitadorEnvioBanco implements LimitadorEnvioInterface
{
// CREATE TABLE envios_formulario (
// id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
// ip_hash CHAR(64) NOT NULL,
// enviado_em DATETIME NOT NULL,
// INDEX idx_envios (ip_hash, enviado_em)
// ) ENGINE=InnoDB;
public function __construct(
private readonly PDO $pdo,
private readonly int $maximo = 3,
private readonly int $janelaSegundos = 300,
) {}
public function verificar(string $ip): array
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) AS total, MIN(enviado_em) AS primeiro
FROM envios_formulario
WHERE ip_hash = :ip
AND enviado_em > DATE_SUB(NOW(), INTERVAL :janela SECOND)'
);
$stmt->bindValue(':ip', $this->hash($ip));
$stmt->bindValue(':janela', $this->janelaSegundos, PDO::PARAM_INT);
$stmt->execute();
$linha = $stmt->fetch(PDO::FETCH_ASSOC);
$total = (int) $linha['total'];
if ($total < $this->maximo) {
return ['permitido' => true, 'restantes' => $this->maximo - $total, 'retry_after' => 0];
}
$libera = strtotime($linha['primeiro']) + $this->janelaSegundos;
return [
'permitido' => false,
'restantes' => 0,
'retry_after' => max(1, $libera - time()),
];
}
public function registrar(string $ip): void
{
$this->pdo->prepare(
'INSERT INTO envios_formulario (ip_hash, enviado_em) VALUES (:ip, NOW())'
)->execute([':ip' => $this->hash($ip)]);
}
public function limpar(): int
{
$stmt = $this->pdo->prepare(
'DELETE FROM envios_formulario
WHERE enviado_em < DATE_SUB(NOW(), INTERVAL :janela SECOND)'
);
$stmt->bindValue(':janela', $this->janelaSegundos * 2, PDO::PARAM_INT);
$stmt->execute();
return $stmt->rowCount();
}
/**
* IP é dado pessoal (LGPD/GDPR). Guardar o hash com um sal da aplicação
* permite contar sem armazenar o endereço em texto puro.
*/
private function hash(string $ip): string
{
return hash_hmac('sha256', $ip, $_ENV['APP_KEY'] ?? 'troque-esta-chave');
}
}
/** Versão em arquivo: para hospedagem sem banco disponível. */
final class LimitadorEnvioArquivo implements LimitadorEnvioInterface
{
public function __construct(
private readonly string $diretorio = '/tmp/form-limits',
private readonly int $maximo = 3,
private readonly int $janelaSegundos = 300,
) {
if (!is_dir($this->diretorio)) {
mkdir($this->diretorio, 0700, true);
}
}
public function verificar(string $ip): array
{
$registros = $this->ler($ip);
if (count($registros) < $this->maximo) {
return ['permitido' => true,
'restantes' => $this->maximo - count($registros), 'retry_after' => 0];
}
return ['permitido' => false, 'restantes' => 0,
'retry_after' => max(1, min($registros) + $this->janelaSegundos - time())];
}
public function registrar(string $ip): void
{
$arquivo = $this->arquivoDe($ip);
$handle = fopen($arquivo, 'c+');
// Sem LOCK_EX, dois envios simultâneos leem o mesmo contador e um
// sobrescreve o outro — o limite passa a ser furável na corrida.
flock($handle, LOCK_EX);
$conteudo = stream_get_contents($handle);
$registros = $conteudo ? (json_decode($conteudo, true) ?: []) : [];
$registros = array_values(array_filter(
$registros, fn(int $t): bool => $t > time() - $this->janelaSegundos
));
$registros[] = time();
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, json_encode($registros));
flock($handle, LOCK_UN);
fclose($handle);
}
private function ler(string $ip): array
{
$arquivo = $this->arquivoDe($ip);
if (!is_file($arquivo)) {
return [];
}
$registros = json_decode((string) file_get_contents($arquivo), true) ?: [];
return array_values(array_filter(
$registros, fn(int $t): bool => $t > time() - $this->janelaSegundos
));
}
private function arquivoDe(string $ip): string
{
return $this->diretorio . '/' . sha1($ip) . '.json';
}
}
// ---------------------------------------- no processamento do formulário
declare(strict_types=1);
session_start();
function ipDoCliente(): string
{
// Só confie em X-Forwarded-For se a aplicação estiver atrás de um proxy
// SEU — o cliente pode forjar o header e trocar de "IP" a cada envio.
$atrasDeProxy = ($_ENV['ATRAS_DE_PROXY'] ?? 'false') === 'true';
if ($atrasDeProxy && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
$limitador = new LimitadorEnvioBanco($pdo, maximo: 3, janelaSegundos: 300);
$ip = ipDoCliente();
$situacao = $limitador->verificar($ip);
if (!$situacao['permitido']) {
http_response_code(429);
header('Retry-After: ' . $situacao['retry_after']);
$minutos = (int) ceil($situacao['retry_after'] / 60);
Flash::erro("Muitos envios seguidos. Tente novamente em {$minutos} minuto(s).");
header('Location: /cadastro.php', true, 303);
exit;
}
// Registra ANTES de processar: se o processamento falhar, a tentativa
// ainda conta — senão um atacante fica livre mandando dados inválidos.
$limitador->registrar($ip);
$erros = (new ValidadorCadastro($pdo))->validar($_POST);
Exercício 5
Antes de o seu código rodar, o PHP já mexeu no que veio do formulário. Descubra o que acontece com os campos perfil.nome, data nascimento e a[b ao chegarem em $_POST, e escreva uma função que compare o que você esperava receber com o que de fato chegou. Depois investigue o max_input_vars: monte um envio com mais campos que o limite e veja quantos sobrevivem — e escreva a verificação que detecta esse corte.
Ver resposta
✓ Resposta: São dois comportamentos silenciosos, e nenhum dos dois gera erro na tela. O primeiro é a troca de nomes: ponto, espaço e colchete não fechado viram _, então <input name="perfil.nome"> chega como $_POST['perfil_nome'] — e quem procura pela chave original recebe string vazia do ?? '', que a validação acusa como "campo obrigatório" num campo que o usuário preencheu. A herança vem do tempo em que cada campo virava uma variável do PHP, e nome de variável não aceita ponto. O segundo é o max_input_vars, que vale 1000 por padrão: um formulário maior que isso não falha — o excedente é simplesmente descartado, e o $_POST que o seu código recebe não tem como saber que foi cortado. É o tipo de defeito que aparece só no cliente com a planilha grande. Contar os campos do corpo bruto antes de confiar custa uma linha e evita a investigação inteira.
<?php
declare(strict_types=1);
// ── 1. O PHP renomeia campos antes de você ver ────────────────────────
// Ponto, espaço e colchete não fechado viram "_" no nome da chave.
$enviadoPeloNavegador = 'perfil.nome=Ana&data nascimento=1990-05-02&a[b=x&normal=ok';
parse_str($enviadoPeloNavegador, $recebido);
foreach ($recebido as $chave => $valor) {
echo " chegou como \$_POST['{$chave}']\n";
}
// perfil_nome, data_nascimento, a_b, normal — três dos quatro trocados.
// Por isso um <input name="perfil.nome"> nunca é encontrado por
// $_POST['perfil.nome']: a chave não existe, e o ?? '' devolve string
// vazia, que a validação acusa como "campo obrigatório".
// ── 2. Diagnóstico: confira se o que chegou é o que você espera ───────
function camposInesperados(array $recebidos, array $esperados): array
{
return [
'faltando' => array_values(array_diff($esperados, array_keys($recebidos))),
'a_mais' => array_values(array_diff(array_keys($recebidos), $esperados)),
];
}
$esperados = ['perfil.nome', 'data nascimento', 'normal'];
print_r(camposInesperados($recebido, $esperados));
// ── 3. O limite silencioso: max_input_vars ────────────────────────────
// O padrão é 1000. Um formulário com mais campos que isso NÃO falha:
// o PHP descarta o excedente e segue em frente.
$limite = (int) ini_get('max_input_vars');
echo " max_input_vars = {$limite}\n";
$grande = implode('&', array_map(fn(int $i) => "item[{$i}]=v{$i}", range(0, 1200)));
@parse_str($grande, $truncado);
$quantos = count($truncado['item'] ?? []);
echo " enviados 1201 campos, recebidos {$quantos}\n";
// A defesa é contar antes de confiar. Em requisição real, o que se conta
// é o corpo bruto — $_POST já vem truncado e não sabe disso.
function corpoFoiTruncado(string $corpoBruto): bool
{
return substr_count($corpoBruto, '&') + 1 > (int) ini_get('max_input_vars');
}
echo ' corpoFoiTruncado(): ' . var_export(corpoFoiTruncado($grande), true) . "\n";