Formulários HTML e PHP

Formulários HTML e PHP

O ciclo completo sem framework: como os dados chegam, validação com filter_var, uma classe reutilizável e o padrão Post/Redirect/Get. Com o que o PHP muda no caminho antes de você ver — nomes de campo reescritos e formulários grandes cortados em silêncio.
PHP

• • 26 min de leitura

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

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";
Comentários

Mais em PHP

PDO Avançado, Prepared Statements e Padrão Repository
PDO Avançado, Prepared Statements e Padrão Repository

Configurar os atributos da conexão, separar sempre os parâmetros da query…

Traits Avançados
Traits Avançados

Trait resolve o que herança não alcança, e traz regras próprias: quem vence…

Boas Práticas e Código Limpo em PHP
Boas Práticas e Código Limpo em PHP

Tipos declarados, nomes que dispensam comentário, funções curtas, early return…