PHP Projeto Formulário de Contato

A lojinha da Ana vende cestas de café da manhã e quer receber mensagens dos clientes pelo site. Neste projeto você constrói o formulário de contato completo: ele confere cada campo, mostra o erro ao lado do campo certo, não apaga o que a pessoa digitou, guarda as mensagens num arquivo e não grava a mesma mensagem duas vezes quando alguém aperta F5.

Você vai aprender
  • Guardar os campos num array $dados e os erros num array $erros com o nome do campo como chave.
  • Validar nome, e-mail (filter_var), assunto e mensagem (mb_strlen), e mostrar cada erro no lugar certo.
  • Devolver os valores para input, select e textarea com segurança.
  • Salvar cada mensagem como uma linha de CSV com fputcsv (e a versão em JSON).
  • O padrão Post/Redirect/Get: por que o F5 duplica envios e como o header('Location: …') resolve.
  • Listar as mensagens recebidas numa tabela, sem deixar uma mensagem maliciosa estragar a página.

Antes de começar

Faça antes o projeto da calculadora: ele mostra, com calma, o "esqueleto" que vamos repetir aqui (o PHP no topo prepara os dados, o HTML embaixo mostra). As aulas que este projeto usa:

O que usamosAula
$_POST, $_GET, ??$_GET e $_POST
filter_var, mb_strlen, mensagens de erroValidar Dados, Acentos e mbstring
htmlspecialcharsEscapar a Saída (XSS)
fopen, fputcsv, fgetcsvLer e Escrever Arquivos, CSV
json_encode, json_decodeJSON
header('Location: …'), exitheader e Redirecionamento
date, fuso horárioDatas e Horas

Crie a pasta e suba o servidor (veja O Servidor Embutido); deixe o terminal aberto e use outro para o curl:

$ mkdir contato
$ cd contato
$ php -S localhost:8000
[Fri Oct 16 20:50:12 2026] PHP 8.5.8 Development Server (http://localhost:8000) started

Cada etapa tem uma caixa "Experimente" no modo web com o arquivo completo daquela etapa: a aba Resultado é a página, e o formulário funciona nela. Os blocos de código das etapas mostram só o que é novo.


As etapas

  1. Etapa 1 · O formulário que não esquece

    Quatro campos: nome, e-mail, assunto (uma lista) e a mensagem (um textarea, a caixa de texto de várias linhas). Em vez de quatro variáveis soltas, os valores ficam juntos no array $dados, e cada campo já volta preenchido depois do envio.

    <?php
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label></p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label></p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label></p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
    TrechoO que faz
    $assuntos = ['duvida' => 'Dúvida', …]As opções da lista: a chave (sem acento nem espaço) é o que viaja no formulário; o valor é o texto que a pessoa vê.
    $dados = ['nome' => trim($_POST['nome'] ?? ''), …]Lê os quatro campos de uma vez. Na primeira visita (GET), nada foi enviado e tudo vale '' (o assunto começa em 'duvida').
    function e(string $texto): stringUm "apelido" curto para htmlspecialchars. Como ele vai aparecer muitas vezes, o nome curto deixa o HTML legível. É um costume comum em projetos PHP.
    value="<?= e($dados['nome']) ?>"Devolve o que foi digitado para o campo, escapado.
    <textarea><?= e($dados['mensagem']) ?></textarea>O textarea não tem value: o conteúdo fica entre as tags. Sem espaços nem quebras de linha extras, senão eles entram no texto.
    $valor === $dados['assunto'] ? ' selected' : ''Marca a opção que foi enviada.

    A primeira visita mostra tudo vazio; um envio volta com os campos preenchidos:

    $ curl -s localhost:8000/ | grep -E 'input|option|textarea'
      <p><label>Nome<br><input name="nome" value=""></label></p>
      <p><label>E-mail<br><input name="email" value=""></label></p>
          <option value="duvida" selected>Dúvida</option>
          <option value="pedido">Pedido</option>
          <option value="elogio">Elogio</option>
          <option value="reclamacao">Reclamação</option>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"></textarea></label></p>
    $ curl -s -d 'nome=Beto&email=beto@exemplo.com&assunto=pedido&mensagem=Entregam no sábado?' localhost:8000/ | grep -E 'input|selected|textarea'
      <p><label>Nome<br><input name="nome" value="Beto"></label></p>
      <p><label>E-mail<br><input name="email" value="beto@exemplo.com"></label></p>
          <option value="pedido" selected>Pedido</option>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40">Entregam no sábado?</textarea></label></p>

    E se alguém tentar "furar" o formulário? O "> tentaria fechar o value, e o </textarea> tentaria fechar a caixa de texto antes da hora. Com o e(), tudo vira texto inofensivo:

    $ curl -s -d 'nome="><script>alert(1)</script>&mensagem=</textarea><b>oi</b>' localhost:8000/ | grep -E 'input|textarea'
      <p><label>Nome<br><input name="nome" value="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"></label></p>
      <p><label>E-mail<br><input name="email" value=""></label></p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40">&lt;/textarea&gt;&lt;b&gt;oi&lt;/b&gt;</textarea></label></p>

    Missão: acrescente um campo opcional Telefone (name="telefone"), logo depois do e-mail, que também volte preenchido. Você vai precisar de uma linha nova em $dados e de um parágrafo novo no formulário. A caixa já envia um telefone para você conferir.

    <?php
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label></p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label></p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label></p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
    <?php
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'telefone' => trim($_POST['telefone'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label></p>
      <p><label>Telefone<br><input name="telefone" value="<?= e($dados['telefone']) ?>"></label></p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label></p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label></p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
  2. Etapa 2 · Validar cada campo

    Agora as regras. Cada erro entra em $erros com o nome do campo como chave: $erros['email'] = '…'. Assim, na hora de mostrar, sabemos exatamente ao lado de qual campo cada mensagem vai.

    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
    }
    RegraPor quê
    mb_strlen($dados['nome']) < 2Conta as letras do nome. O strlen comum conta bytes, e cada letra acentuada vale 2 bytes em UTF-8: strlen('Zé') é 3. Para texto em português, use as funções mb_ (veja Acentos e mbstring).
    filter_var($email, FILTER_VALIDATE_EMAIL)"Filtrar a variável como e-mail": devolve o próprio e-mail se ele tiver um formato válido, ou false. No laboratório, ana@exemplo.com passou; ana@exemplo, ana.exemplo.com e ana@localhost deram false. Formato válido não quer dizer que o e-mail existe; só que parece um e-mail.
    === falseCompara com false de forma estrita, porque o retorno bom é um texto.
    array_key_exists($dados['assunto'], $assuntos)O assunto tem que ser uma das chaves da lista. Qualquer pessoa pode enviar assunto=spam pelo curl.
    "… (tem $tamanho)."Aspas duplas trocam $tamanho pelo valor (veja Interpolação). Dizer quanto falta ajuda a pessoa a corrigir.

    Ao lado de cada campo, o erro aparece só se existir. O mesmo padrão se repete para os quatro campos:

    <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>

    E, antes do formulário, uma confirmação quando não houve erro nenhum (provisória: na etapa 3 ela muda):

    <?php if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$erros): ?>
      <p class="sucesso">Tudo certo, <?= e($dados['nome']) ?>! (ainda não salvamos nada)</p>
    <?php endif; ?>
    $ curl -s -d 'nome=A&email=ana@exemplo&assunto=spam&mensagem=Oi' localhost:8000/ | grep erro
        <br><span class="erro">Escreva o seu nome.</span>  </p>
        <br><span class="erro">Esse e-mail não parece válido.</span>  </p>
        <br><span class="erro">Escolha um assunto da lista.</span>  </p>
        <br><span class="erro">A mensagem precisa ter entre 10 e 1000 caracteres (tem 2).</span>  </p>
    $ curl -s -d 'nome=Beto&email=beto@exemplo.com&assunto=pedido&mensagem=Entregam no sábado?' localhost:8000/ | grep -E 'erro|sucesso'
      <p class="sucesso">Tudo certo, Beto! (ainda não salvamos nada)</p>
    E o required do HTML?

    Você pode (e deve) pôr required e type="email" nos campos: o navegador avisa antes de enviar, o que é mais rápido para a pessoa. Mas isso é só conforto: o curl acima ignorou tudo isso. A validação que protege é a do PHP.

    Missão: a Ana anda recebendo propaganda. Acrescente uma regra: se a mensagem contiver http, o erro do campo mensagem é Links não são permitidos na mensagem. Use str_contains($dados['mensagem'], 'http').

    <?php
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
    }
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <?php if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$erros): ?>
      <p class="sucesso">Tudo certo, <?= e($dados['nome']) ?>! (ainda não salvamos nada)</p>
    <?php endif; ?>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
        <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
      </p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label>
        <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
      </p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
        <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
      </p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
    <?php
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
        if (str_contains($dados['mensagem'], 'http')) {
            $erros['mensagem'] = 'Links não são permitidos na mensagem.';
        }
    }
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <?php if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$erros): ?>
      <p class="sucesso">Tudo certo, <?= e($dados['nome']) ?>! (ainda não salvamos nada)</p>
    <?php endif; ?>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
        <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
      </p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label>
        <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
      </p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
        <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
      </p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
  3. Etapa 3 · Salvar a mensagem num arquivo CSV

    Quando não há erros, a mensagem vai para o fim do arquivo mensagens.csv, uma linha por mensagem. CSV ("valores separados por vírgula") é o formato de planilha mais simples que existe: abre no LibreOffice Calc e no Excel.

    <?php
    date_default_timezone_set('America/Sao_Paulo');
    ...
    $erros = [];
    $enviado = false;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        // ... as quatro validações da etapa 2 ...
    
        if (!$erros) {
            $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
            $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
            fputcsv($arquivo, $linha, ',', '"', '');
            fclose($arquivo);
            $enviado = true;
        }
    }
    date_default_timezone_set(…)
    Diz ao PHP o fuso horário. Sem isso (e sem configurar o php.ini), o PHP usa UTC: no laboratório, date_default_timezone_get() respondeu UTC, e as mensagens sairiam com 3 horas a mais.
    fopen(…, 'a')
    Abre o arquivo no modo 'a' (append, "acrescentar"): escreve no fim, sem apagar o que já existe, e cria o arquivo se ele não existir.
    __DIR__ . '/mensagens.csv'
    __DIR__ é a pasta do próprio index.php. Usar o caminho completo evita surpresas quando o PHP roda a partir de outra pasta.
    fputcsv($arquivo, $linha, ',', '"', '')
    Escreve o array como uma linha CSV, cuidando sozinho das vírgulas e aspas dentro do texto. Os três últimos argumentos são o separador, a aspa e o caractere de escape; o escape vazio '' é o recomendado e, no PHP 8.4+, precisa ser informado (veja "Quando dá errado").
    fclose($arquivo)
    Fecha o arquivo e garante que tudo foi gravado.
    $ curl -s -d 'nome=Beto Souza&email=beto@exemplo.com&assunto=pedido&mensagem=Vocês entregam no sábado? Queria duas cestas.' localhost:8000/ | grep sucesso
      <p class="sucesso">Obrigada, Beto Souza! Sua mensagem foi registrada.</p>
    $ cat mensagens.csv
    "2026-10-16 20:50:07","Beto Souza",beto@exemplo.com,pedido,"Vocês entregam no sábado? Queria duas cestas."

    Repare que o fputcsv pôs aspas só onde precisava (textos com espaço). E a vírgula e as aspas dentro da mensagem da Carla não quebram a linha: o texto ganha aspas em volta e as aspas internas viram duas (""), a regra do CSV:

    $ curl -s -d 'nome=Carla&email=carla@exemplo.com&assunto=elogio&mensagem=Adorei a cesta "grande", obrigada!' localhost:8000/ | grep sucesso
      <p class="sucesso">Obrigada, Carla! Sua mensagem foi registrada.</p>
    $ curl -s -d 'nome=Carla&email=carla@exemplo.com&assunto=elogio&mensagem=Adorei a cesta "grande", obrigada!' localhost:8000/ | grep sucesso
      <p class="sucesso">Obrigada, Carla! Sua mensagem foi registrada.</p>      « o mesmo envio de novo: é o que o F5 faz
    $ cat mensagens.csv
    "2026-10-16 20:50:07","Beto Souza",beto@exemplo.com,pedido,"Vocês entregam no sábado? Queria duas cestas."
    "2026-10-16 20:50:07",Carla,carla@exemplo.com,elogio,"Adorei a cesta ""grande"", obrigada!"
    "2026-10-16 20:50:07",Carla,carla@exemplo.com,elogio,"Adorei a cesta ""grande"", obrigada!"      « duplicada!

    Achou o problema? A página de "Obrigada" é a resposta de um POST. Se a Carla apertar F5 nela, o navegador pergunta "Reenviar os dados do formulário?" e, se ela confirmar, o mesmo POST vai de novo: mensagem duplicada. A próxima etapa resolve.

    Missão: depois de salvar, conte quantas mensagens o arquivo já tem e mostre Esta é a mensagem número N. abaixo do agradecimento. Dica: file(…) devolve um array com as linhas do arquivo, e count conta. Envie o formulário mais vezes na aba Resultado e veja o número subir.

    <?php
    date_default_timezone_set('America/Sao_Paulo');
    
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    $erros = [];
    $enviado = false;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
    
        if (!$erros) {
            $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
            $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
            fputcsv($arquivo, $linha, ',', '"', '');
            fclose($arquivo);
            $enviado = true;
        }
    }
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <?php if ($enviado): ?>
      <p class="sucesso">Obrigada, <?= e($dados['nome']) ?>! Sua mensagem foi registrada.</p>
    <?php endif; ?>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
        <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
      </p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label>
        <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
      </p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
        <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
      </p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
    <?php
    date_default_timezone_set('America/Sao_Paulo');
    
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    $erros = [];
    $enviado = false;
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
    
        if (!$erros) {
            $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
            $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
            fputcsv($arquivo, $linha, ',', '"', '');
            fclose($arquivo);
            $enviado = true;
            $total = count(file(__DIR__ . '/mensagens.csv'));
        }
    }
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <?php if ($enviado): ?>
      <p class="sucesso">Obrigada, <?= e($dados['nome']) ?>! Sua mensagem foi registrada.</p>
      <p>Esta é a mensagem número <?= $total ?>.</p>
    <?php endif; ?>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
        <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
      </p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label>
        <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
      </p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
        <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
      </p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
  4. Etapa 4 · Post/Redirect/Get: acabar com o envio duplicado

    O nome descreve a receita: o navegador faz o POST; o PHP salva e, em vez de mostrar a página, responde "vá para outro endereço" (Redirect); o navegador obedece e faz um GET nesse endereço. A página que a pessoa vê no fim é a resposta de um GET, e o F5 repete só o GET, que não grava nada.

        if (!$erros) {
            $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
            $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
            fputcsv($arquivo, $linha, ',', '"', '');
            fclose($arquivo);
    
            header('Location: index.php?enviado=1', true, 303);
            exit;
        }

    E a confirmação agora olha o endereço, não uma variável:

    <?php if (isset($_GET['enviado'])): ?>
      <p class="sucesso">Obrigada! Sua mensagem foi registrada. Responderemos em breve.</p>
    <?php endif; ?>
    header(
    Envia um cabeçalho HTTP: uma instrução para o navegador que vai antes do conteúdo da página.
    'Location: index.php?enviado=1'
    "O que você procura está neste endereço." O ?enviado=1 é só um aviso para a próxima página saber que deu certo.
    true, 303
    Substitui um Location anterior e usa o código 303 See Other ("veja outro"), que manda o navegador buscar o novo endereço com GET. Sem o 303, o PHP usa 302, que os navegadores também tratam como GET; o 303 deixa a intenção explícita.
    exit;
    Para o script aqui. Sem ele, o PHP continuaria e geraria a página inteira à toa (o navegador nem a mostraria).

    O curl -i mostra os cabeçalhos da resposta. Repare: nada de HTML, só a ordem de ir para outro lugar:

    $ curl -si -d 'nome=Carla&email=carla@exemplo.com&assunto=elogio&mensagem=Adorei a cesta "grande", obrigada!' localhost:8000/
    HTTP/1.1 303 See Other
    Host: localhost:8000
    Date: Fri, 16 Oct 2026 23:50:13 GMT
    Connection: close
    X-Powered-By: PHP/8.5.8
    Location: index.php?enviado=1
    Content-type: text/html; charset=UTF-8
    

    Com -L, o curl segue o redirecionamento, como o navegador. A página final tem o agradecimento e o formulário limpo, pronto para outra mensagem:

    $ curl -sL -d 'nome=Davi&email=davi@exemplo.com&assunto=duvida&mensagem=Qual o horário de vocês?' localhost:8000/ | grep -E 'sucesso|value='
      <p class="sucesso">Obrigada! Sua mensagem foi registrada. Responderemos em breve.</p>
      <p><label>Nome<br><input name="nome" value=""></label>
      <p><label>E-mail<br><input name="email" value=""></label>
          <option value="duvida" selected>Dúvida</option>
          <option value="pedido">Pedido</option>
          <option value="elogio">Elogio</option>
          <option value="reclamacao">Reclamação</option>
    $ cat mensagens.csv
    "2026-10-16 20:50:13",Carla,carla@exemplo.com,elogio,"Adorei a cesta ""grande"", obrigada!"
    "2026-10-16 20:50:13",Davi,davi@exemplo.com,duvida,"Qual o horário de vocês?"

    O terminal do servidor conta a história inteira: o POST recebeu 303, e logo depois veio o GET para /index.php?enviado=1:

    [Fri Oct 16 20:50:13 2026] 127.0.0.1:57254 [303]: POST /
    [Fri Oct 16 20:50:13 2026] 127.0.0.1:57264 [200]: GET /index.php?enviado=1
    E quando há erros?

    Aí não redirecionamos: a resposta do próprio POST mostra o formulário com os erros e os valores digitados. Redirecionar faria a pessoa perder tudo. O PRG é só para o caminho de sucesso.

    Missão: agradeça pelo nome. Leve o nome no endereço do redirecionamento ('Location: index.php?enviado=' . urlencode($dados['nome'])) e, na página, mostre Obrigada, <?= e($_GET['enviado']) ?>!. O urlencode protege espaços e acentos no endereço; o e() protege a página, porque qualquer um pode digitar ?enviado=<script> na barra de endereço.

    <?php
    date_default_timezone_set('America/Sao_Paulo');
    
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
    
        if (!$erros) {
            $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
            $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
            fputcsv($arquivo, $linha, ',', '"', '');
            fclose($arquivo);
    
            header('Location: index.php?enviado=1', true, 303);
            exit;
        }
    }
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <?php if (isset($_GET['enviado'])): ?>
      <p class="sucesso">Obrigada! Sua mensagem foi registrada. Responderemos em breve.</p>
    <?php endif; ?>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
        <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
      </p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label>
        <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
      </p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
        <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
      </p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
    <?php
    date_default_timezone_set('America/Sao_Paulo');
    
    $assuntos = [
        'duvida'     => 'Dúvida',
        'pedido'     => 'Pedido',
        'elogio'     => 'Elogio',
        'reclamacao' => 'Reclamação',
    ];
    
    $dados = [
        'nome'     => trim($_POST['nome'] ?? ''),
        'email'    => trim($_POST['email'] ?? ''),
        'assunto'  => $_POST['assunto'] ?? 'duvida',
        'mensagem' => trim($_POST['mensagem'] ?? ''),
    ];
    
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($dados['nome']) < 2) {
            $erros['nome'] = 'Escreva o seu nome.';
        }
        if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
            $erros['email'] = 'Esse e-mail não parece válido.';
        }
        if (!array_key_exists($dados['assunto'], $assuntos)) {
            $erros['assunto'] = 'Escolha um assunto da lista.';
        }
        $tamanho = mb_strlen($dados['mensagem']);
        if ($tamanho < 10 || $tamanho > 1000) {
            $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
        }
    
        if (!$erros) {
            $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
            $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
            fputcsv($arquivo, $linha, ',', '"', '');
            fclose($arquivo);
    
            header('Location: index.php?enviado=' . urlencode($dados['nome']), true, 303);
            exit;
        }
    }
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Fale com a Ana</title>
    </head>
    <body>
    <h1>Fale com a lojinha da Ana</h1>
    <?php if (isset($_GET['enviado'])): ?>
      <p class="sucesso">Obrigada, <?= e($_GET['enviado']) ?>! Sua mensagem foi registrada. Responderemos em breve.</p>
    <?php endif; ?>
    <form method="post">
      <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
        <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
      </p>
      <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
        <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
      </p>
      <p><label>Assunto<br>
        <select name="assunto">
    <?php foreach ($assuntos as $valor => $texto): ?>
          <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
    <?php endforeach; ?>
        </select></label>
        <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
      </p>
      <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
        <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
      </p>
      <p><button>Enviar</button></p>
    </form>
    </body>
    </html>
    
  5. Etapa 5 · Ler as mensagens recebidas

    A Ana precisa ler o que chegou. Um segundo arquivo, mensagens.php, lê o CSV e monta uma tabela. É aqui que o htmlspecialchars faz a maior diferença: o texto das mensagens foi escrito por qualquer pessoa da internet.

    <?php
    $mensagens = [];
    $caminho = __DIR__ . '/mensagens.csv';
    
    if (file_exists($caminho)) {
        $arquivo = fopen($caminho, 'r');
        while (($linha = fgetcsv($arquivo, null, ',', '"', '')) !== false) {
            $mensagens[] = $linha;
        }
        fclose($arquivo);
    }
    
    $mensagens = array_reverse($mensagens); // as mais novas primeiro
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Mensagens recebidas</title>
    </head>
    <body>
    <h1>Mensagens recebidas (<?= count($mensagens) ?>)</h1>
    <?php if (!$mensagens): ?>
      <p>Nenhuma mensagem ainda.</p>
    <?php else: ?>
    <table border="1" cellpadding="4">
      <tr><th>Quando</th><th>Nome</th><th>E-mail</th><th>Assunto</th><th>Mensagem</th></tr>
    <?php foreach ($mensagens as [$quando, $nome, $email, $assunto, $texto]): ?>
      <tr>
        <td><?= htmlspecialchars($quando) ?></td>
        <td><?= htmlspecialchars($nome) ?></td>
        <td><?= htmlspecialchars($email) ?></td>
        <td><?= htmlspecialchars($assunto) ?></td>
        <td><?= nl2br(htmlspecialchars($texto)) ?></td>
      </tr>
    <?php endforeach; ?>
    </table>
    <?php endif; ?>
    </body>
    </html>
    
    TrechoO que faz
    file_exists($caminho)Antes da primeira mensagem, o arquivo não existe; sem essa checagem, o fopen daria um Warning.
    while (($linha = fgetcsv(…)) !== false)Lê uma linha por vez, já separada num array, até o fim do arquivo (quando fgetcsv devolve false).
    array_reverse($mensagens)O arquivo tem as mais antigas primeiro; invertemos para ver as novas no topo.
    foreach ($mensagens as [$quando, $nome, …])Desestruturação: cada posição da linha vai direto para uma variável com nome (veja Desestruturação).
    nl2br(htmlspecialchars($texto))Primeiro escapa; depois troca as quebras de linha por <br />, para a mensagem manter os parágrafos. A ordem importa: ao contrário, o <br /> seria escapado e apareceria escrito na tela.

    Com um CSV que tem uma mensagem "maldosa" (a segunda linha), a página continua inteira:

    "2026-10-16 20:50:12","Beto Souza",beto@exemplo.com,pedido,"Vocês entregam no sábado? Queria duas cestas."
    "2026-10-16 20:52:40","Carla <script>",carla@exemplo.com,duvida,"Testando: <b>negrito</b> e ""aspas"""
    
    $ curl -s localhost:8000/mensagens.php | sed -n '/<h1>/,/<\/table>/p'
    <h1>Mensagens recebidas (2)</h1>
    <table border="1" cellpadding="4">
      <tr><th>Quando</th><th>Nome</th><th>E-mail</th><th>Assunto</th><th>Mensagem</th></tr>
      <tr>
        <td>2026-10-16 20:52:40</td>
        <td>Carla &lt;script&gt;</td>
        <td>carla@exemplo.com</td>
        <td>duvida</td>
        <td>Testando: &lt;b&gt;negrito&lt;/b&gt; e &quot;aspas&quot;</td>
      </tr>
      <tr>
        <td>2026-10-16 20:50:12</td>
        <td>Beto Souza</td>
        <td>beto@exemplo.com</td>
        <td>pedido</td>
        <td>Vocês entregam no sábado? Queria duas cestas.</td>
      </tr>
    </table>
    Esta página não tem senha

    Do jeito que está, qualquer um que souber o endereço /mensagens.php lê as mensagens. Num site de verdade, ela ficaria atrás de um login: é exatamente o que o projeto Login com Sessão ensina. E o arquivo mensagens.csv deveria ficar fora da pasta pública do site, para não ser baixado direto pelo navegador.

    Missão: esta caixa tem duas abas, mensagens.php e o mensagens.csv de exemplo. Faça a página aceitar um filtro no endereço: com ?assunto=duvida, só aparecem as dúvidas. Antes do array_reverse, use array_filter($mensagens, fn($m) => $m[3] === $_GET['assunto']) quando isset($_GET['assunto']).

    <?php
    $mensagens = [];
    $caminho = __DIR__ . '/mensagens.csv';
    
    if (file_exists($caminho)) {
        $arquivo = fopen($caminho, 'r');
        while (($linha = fgetcsv($arquivo, null, ',', '"', '')) !== false) {
            $mensagens[] = $linha;
        }
        fclose($arquivo);
    }
    
    $mensagens = array_reverse($mensagens); // as mais novas primeiro
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Mensagens recebidas</title>
    </head>
    <body>
    <h1>Mensagens recebidas (<?= count($mensagens) ?>)</h1>
    <?php if (!$mensagens): ?>
      <p>Nenhuma mensagem ainda.</p>
    <?php else: ?>
    <table border="1" cellpadding="4">
      <tr><th>Quando</th><th>Nome</th><th>E-mail</th><th>Assunto</th><th>Mensagem</th></tr>
    <?php foreach ($mensagens as [$quando, $nome, $email, $assunto, $texto]): ?>
      <tr>
        <td><?= htmlspecialchars($quando) ?></td>
        <td><?= htmlspecialchars($nome) ?></td>
        <td><?= htmlspecialchars($email) ?></td>
        <td><?= htmlspecialchars($assunto) ?></td>
        <td><?= nl2br(htmlspecialchars($texto)) ?></td>
      </tr>
    <?php endforeach; ?>
    </table>
    <?php endif; ?>
    </body>
    </html>
    
    "2026-10-16 20:50:12","Beto Souza",beto@exemplo.com,pedido,"Vocês entregam no sábado? Queria duas cestas."
    "2026-10-16 20:52:40","Carla <script>",carla@exemplo.com,duvida,"Testando: <b>negrito</b> e ""aspas"""
    
    <?php
    $mensagens = [];
    $caminho = __DIR__ . '/mensagens.csv';
    
    if (file_exists($caminho)) {
        $arquivo = fopen($caminho, 'r');
        while (($linha = fgetcsv($arquivo, null, ',', '"', '')) !== false) {
            $mensagens[] = $linha;
        }
        fclose($arquivo);
    }
    
    if (isset($_GET['assunto'])) {
        $mensagens = array_filter($mensagens, fn($m) => $m[3] === $_GET['assunto']);
    }
    
    $mensagens = array_reverse($mensagens); // as mais novas primeiro
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
    <meta charset="utf-8">
    <title>Mensagens recebidas</title>
    </head>
    <body>
    <h1>Mensagens recebidas (<?= count($mensagens) ?>)</h1>
    <?php if (!$mensagens): ?>
      <p>Nenhuma mensagem ainda.</p>
    <?php else: ?>
    <table border="1" cellpadding="4">
      <tr><th>Quando</th><th>Nome</th><th>E-mail</th><th>Assunto</th><th>Mensagem</th></tr>
    <?php foreach ($mensagens as [$quando, $nome, $email, $assunto, $texto]): ?>
      <tr>
        <td><?= htmlspecialchars($quando) ?></td>
        <td><?= htmlspecialchars($nome) ?></td>
        <td><?= htmlspecialchars($email) ?></td>
        <td><?= htmlspecialchars($assunto) ?></td>
        <td><?= nl2br(htmlspecialchars($texto)) ?></td>
      </tr>
    <?php endforeach; ?>
    </table>
    <?php endif; ?>
    </body>
    </html>
    
  6. Etapa 6 (alternativa) · Salvar em JSON em vez de CSV

    CSV é ótimo para abrir numa planilha. Se as mensagens forem lidas por outro programa (uma API, um painel em JavaScript), JSON costuma ser mais prático: cada mensagem guarda os nomes dos campos. A troca fica toda numa função:

    function salvarMensagem(array $mensagem): void
    {
        $caminho = __DIR__ . '/mensagens.json';
    
        $lista = [];
        if (file_exists($caminho)) {
            $lista = json_decode(file_get_contents($caminho), true) ?? [];
        }
    
        $lista[] = $mensagem;
    
        file_put_contents(
            $caminho,
            json_encode($lista, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
            LOCK_EX
        );
    }

    E, no lugar das quatro linhas do CSV, uma só:

            salvarMensagem(['quando' => date('Y-m-d H:i:s')] + $dados);
    TrechoO que faz
    json_decode(…, true)Lê o JSON como array associativo (o true). Se o arquivo estiver vazio ou estragado, devolve null, e o ?? [] recomeça com uma lista vazia.
    $lista[] = $mensagemAcrescenta a nova mensagem no fim da lista.
    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODEArquivo bonito, com recuo, e acentos escritos como acentos (sem a opção, sábado viraria sábado).
    LOCK_EXTrava o arquivo enquanto escreve, para dois envios ao mesmo tempo não se atropelarem na escrita.
    ['quando' => …] + $dadosO + junta dois arrays associativos: a data primeiro, depois os quatro campos.
    $ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -d 'nome=Beto Souza&email=beto@exemplo.com&assunto=pedido&mensagem=Vocês entregam no sábado?' localhost:8000/
    303 http://localhost:8000/index.php?enviado=1
    $ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -d 'nome=Carla&email=carla@exemplo.com&assunto=elogio&mensagem=Adorei a cesta "grande"!' localhost:8000/
    303 http://localhost:8000/index.php?enviado=1
    $ cat mensagens.json
    [
        {
            "quando": "2026-10-16 20:51:52",
            "nome": "Beto Souza",
            "email": "beto@exemplo.com",
            "assunto": "pedido",
            "mensagem": "Vocês entregam no sábado?"
        },
        {
            "quando": "2026-10-16 20:51:52",
            "nome": "Carla",
            "email": "carla@exemplo.com",
            "assunto": "elogio",
            "mensagem": "Adorei a cesta \"grande\"!"
        }
    ]

    (O -o /dev/null -w '…' faz o curl mostrar só o código da resposta e o endereço do redirecionamento.)

    Qual usar? Para uma lojinha, os dois servem. O CSV só acrescenta uma linha (rápido, mesmo com milhares de mensagens); o JSON lê e regrava o arquivo inteiro a cada envio, o que fica lento com muitos dados. Quando o volume crescer, o lugar certo é um banco de dados, como no projeto Tarefas com SQLite.


Quando dá errado

Warning: Cannot modify header information - headers already sent

Um arquivo com uma linha em branco antes do <?php e um header('Location: …') logo depois:

$ curl -si localhost:8000/teste.php
HTTP/1.1 200 OK
Host: localhost:8000
Date: Fri, 16 Oct 2026 23:51:44 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Content-type: text/html; charset=UTF-8


<br />
<b>Warning</b>:  Cannot modify header information - headers already sent by (output started at /home/ana/contato/teste.php:1) in <b>/home/ana/contato/teste.php</b> on line <b>3</b><br />

O que significa: "não posso mudar os cabeçalhos: eles já foram enviados (a saída começou em teste.php:1)". Os cabeçalhos vão antes do conteúdo; a linha em branco já era conteúdo, então o PHP já tinha mandado os cabeçalhos (com 200 OK), e o redirecionamento não aconteceu. Como consertar: o <?php tem que ser a primeiríssima coisa do arquivo, e nenhum echo ou HTML pode vir antes do header(). A mensagem diz exatamente o arquivo e a linha culpados. (Algumas instalações têm output_buffering ligado no php.ini e escondem esse erro; não conte com isso.)

Deprecated: fputcsv(): the $escape parameter must be provided as its default value will change

Aparece no PHP 8.4+ quando você chama fputcsv($arquivo, $linha) só com dois argumentos (o mesmo vale para fgetcsv). O que significa: "informe o parâmetro $escape, porque o valor padrão dele vai mudar". Como consertar: passe os três: fputcsv($arquivo, $linha, ',', '"', ''). O escape vazio segue o padrão CSV de verdade (aspas dobradas).

Fatal error: Uncaught TypeError: trim(): Argument #1 ($string) must be of type string, array given

Alguém enviou nome[]=Ana (com colchetes), e o PHP transformou o campo num array:

$ curl -s -d 'nome[]=Ana' localhost:8000/index.php | head -8
<br />
<b>Fatal error</b>:  Uncaught TypeError: trim(): Argument #1 ($string) must be of type string, array given in /home/ana/contato/index.php:12
Stack trace:
#0 /home/ana/contato/index.php(12): trim(Array)
#1 {main}
  thrown in <b>/home/ana/contato/index.php</b> on line <b>12</b><br />

Não é um furo de segurança (a página só parou), mas é feio. Como consertar: leia o campo só se for texto: is_string($_POST['nome'] ?? null) ? trim($_POST['nome']) : ''. É um dos desafios do fim.

Warning: fopen(…/mensagens.csv): Failed to open stream: Permission denied

No laboratório, tiramos a permissão de escrita de uma pasta (chmod a-w trancada) e enviamos uma mensagem válida para o index.php dela:

$ curl -s -d 'nome=Beto&email=beto@exemplo.com&assunto=pedido&mensagem=Entregam no sábado?' localhost:8000/trancada/index.php | head -12
<br />
<b>Warning</b>:  fopen(/home/ana/contato/trancada/mensagens.csv): Failed to open stream: Permission denied in <b>/home/ana/contato/trancada/index.php</b> on line <b>37</b><br />
<br />
<b>Fatal error</b>:  Uncaught TypeError: fputcsv(): Argument #1 ($stream) must be of type resource, false given in /home/ana/contato/trancada/index.php:38
Stack trace:
#0 /home/ana/contato/trancada/index.php(38): fputcsv(false, Array, ',', '&quot;', '')
#1 {main}
  thrown in <b>/home/ana/contato/trancada/index.php</b> on line <b>38</b><br />

Dois erros em cadeia: o fopen não conseguiu abrir ("permissão negada") e devolveu false; o fputcsv recebeu esse false no lugar do arquivo e parou tudo. Num servidor de verdade isso acontece porque o PHP roda como outro usuário (por exemplo www-data), que não pode escrever na pasta do site. Como consertar: dê permissão de escrita só na pasta dos dados para esse usuário, de preferência uma pasta fora da pública. Com o php -S isso não acontece, porque ele roda como você.

As datas saem 3 horas adiantadas

Faltou o fuso horário: sem date_default_timezone_set nem date.timezone no php.ini, o PHP usa UTC. Veja Datas e Horas.


O código completo

O index.php final (versão CSV, com Post/Redirect/Get):

<?php
date_default_timezone_set('America/Sao_Paulo');

$assuntos = [
    'duvida'     => 'Dúvida',
    'pedido'     => 'Pedido',
    'elogio'     => 'Elogio',
    'reclamacao' => 'Reclamação',
];

$dados = [
    'nome'     => trim($_POST['nome'] ?? ''),
    'email'    => trim($_POST['email'] ?? ''),
    'assunto'  => $_POST['assunto'] ?? 'duvida',
    'mensagem' => trim($_POST['mensagem'] ?? ''),
];

$erros = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (mb_strlen($dados['nome']) < 2) {
        $erros['nome'] = 'Escreva o seu nome.';
    }
    if (filter_var($dados['email'], FILTER_VALIDATE_EMAIL) === false) {
        $erros['email'] = 'Esse e-mail não parece válido.';
    }
    if (!array_key_exists($dados['assunto'], $assuntos)) {
        $erros['assunto'] = 'Escolha um assunto da lista.';
    }
    $tamanho = mb_strlen($dados['mensagem']);
    if ($tamanho < 10 || $tamanho > 1000) {
        $erros['mensagem'] = "A mensagem precisa ter entre 10 e 1000 caracteres (tem $tamanho).";
    }

    if (!$erros) {
        $linha = [date('Y-m-d H:i:s'), $dados['nome'], $dados['email'], $dados['assunto'], $dados['mensagem']];
        $arquivo = fopen(__DIR__ . '/mensagens.csv', 'a');
        fputcsv($arquivo, $linha, ',', '"', '');
        fclose($arquivo);

        header('Location: index.php?enviado=1', true, 303);
        exit;
    }
}

function e(string $texto): string
{
    return htmlspecialchars($texto);
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Fale com a Ana</title>
</head>
<body>
<h1>Fale com a lojinha da Ana</h1>
<?php if (isset($_GET['enviado'])): ?>
  <p class="sucesso">Obrigada! Sua mensagem foi registrada. Responderemos em breve.</p>
<?php endif; ?>
<form method="post">
  <p><label>Nome<br><input name="nome" value="<?= e($dados['nome']) ?>"></label>
    <?php if (isset($erros['nome'])): ?><br><span class="erro"><?= e($erros['nome']) ?></span><?php endif; ?>
  </p>
  <p><label>E-mail<br><input name="email" value="<?= e($dados['email']) ?>"></label>
    <?php if (isset($erros['email'])): ?><br><span class="erro"><?= e($erros['email']) ?></span><?php endif; ?>
  </p>
  <p><label>Assunto<br>
    <select name="assunto">
<?php foreach ($assuntos as $valor => $texto): ?>
      <option value="<?= $valor ?>"<?= $valor === $dados['assunto'] ? ' selected' : '' ?>><?= $texto ?></option>
<?php endforeach; ?>
    </select></label>
    <?php if (isset($erros['assunto'])): ?><br><span class="erro"><?= e($erros['assunto']) ?></span><?php endif; ?>
  </p>
  <p><label>Mensagem<br><textarea name="mensagem" rows="5" cols="40"><?= e($dados['mensagem']) ?></textarea></label>
    <?php if (isset($erros['mensagem'])): ?><br><span class="erro"><?= e($erros['mensagem']) ?></span><?php endif; ?>
  </p>
  <p><button>Enviar</button></p>
</form>
</body>
</html>

E o mensagens.php é o da etapa 5, sem mudanças.

Desafios extras

  1. Campos à prova de array: uma função campo(string $nome): string que devolve trim do campo se ele for texto e '' em qualquer outro caso. Use-a para montar $dados.
  2. Pote de mel (honeypot) contra robôs: um campo escondido com CSS, <input name="site">. Pessoas não o veem; robôs preenchem tudo. Se site vier preenchido, redirecione como se tivesse dado certo, mas não salve.
  3. Limite de envios: no máximo uma mensagem por minuto por pessoa, usando uma sessão (veja Sessões).
  4. Contador no topo: no mensagens.php, mostre quantas mensagens há de cada assunto (dica: array_count_values(array_column($mensagens, 3))).
  5. Mover os dados: guarde o CSV numa pasta dados/ fora da pasta pública e ajuste o caminho com __DIR__ . '/../dados/mensagens.csv'.

Missão final: as mensagens agora estão em mensagens.json (aba ao lado, gerado pela etapa 6). Mude o mensagens.php para ler o JSON: troque o bloco do fopen/fgetcsv por json_decode(file_get_contents($caminho), true) ?? [], e o foreach para desestruturar pelas chaves: foreach ($mensagens as ['quando' => $quando, 'nome' => $nome, …]).

<?php
$mensagens = [];
$caminho = __DIR__ . '/mensagens.csv';

if (file_exists($caminho)) {
    $arquivo = fopen($caminho, 'r');
    while (($linha = fgetcsv($arquivo, null, ',', '"', '')) !== false) {
        $mensagens[] = $linha;
    }
    fclose($arquivo);
}

$mensagens = array_reverse($mensagens); // as mais novas primeiro
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Mensagens recebidas</title>
</head>
<body>
<h1>Mensagens recebidas (<?= count($mensagens) ?>)</h1>
<?php if (!$mensagens): ?>
  <p>Nenhuma mensagem ainda.</p>
<?php else: ?>
<table border="1" cellpadding="4">
  <tr><th>Quando</th><th>Nome</th><th>E-mail</th><th>Assunto</th><th>Mensagem</th></tr>
<?php foreach ($mensagens as [$quando, $nome, $email, $assunto, $texto]): ?>
  <tr>
    <td><?= htmlspecialchars($quando) ?></td>
    <td><?= htmlspecialchars($nome) ?></td>
    <td><?= htmlspecialchars($email) ?></td>
    <td><?= htmlspecialchars($assunto) ?></td>
    <td><?= nl2br(htmlspecialchars($texto)) ?></td>
  </tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</body>
</html>
[
    {
        "quando": "2026-10-16 20:51:52",
        "nome": "Beto Souza",
        "email": "beto@exemplo.com",
        "assunto": "pedido",
        "mensagem": "Vocês entregam no sábado?"
    },
    {
        "quando": "2026-10-16 20:51:52",
        "nome": "Carla",
        "email": "carla@exemplo.com",
        "assunto": "elogio",
        "mensagem": "Adorei a cesta \"grande\"!"
    }
]
<?php
$mensagens = [];
$caminho = __DIR__ . '/mensagens.json';

if (file_exists($caminho)) {
    $mensagens = json_decode(file_get_contents($caminho), true) ?? [];
}

$mensagens = array_reverse($mensagens); // as mais novas primeiro
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Mensagens recebidas</title>
</head>
<body>
<h1>Mensagens recebidas (<?= count($mensagens) ?>)</h1>
<?php if (!$mensagens): ?>
  <p>Nenhuma mensagem ainda.</p>
<?php else: ?>
<table border="1" cellpadding="4">
  <tr><th>Quando</th><th>Nome</th><th>E-mail</th><th>Assunto</th><th>Mensagem</th></tr>
<?php foreach ($mensagens as ['quando' => $quando, 'nome' => $nome, 'email' => $email, 'assunto' => $assunto, 'mensagem' => $texto]): ?>
  <tr>
    <td><?= htmlspecialchars($quando) ?></td>
    <td><?= htmlspecialchars($nome) ?></td>
    <td><?= htmlspecialchars($email) ?></td>
    <td><?= htmlspecialchars($assunto) ?></td>
    <td><?= nl2br(htmlspecialchars($texto)) ?></td>
  </tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</body>
</html>

Exercícios

Complete o redirecionamento do Post/Redirect/Get e a linha que impede o resto do script de rodar:

('Location: index.php?enviado=1', true, );

Complete o modo do fopen que escreve no fim do arquivo sem apagar o que já existe:

$arquivo = fopen(__DIR__ . '/mensagens.csv', '');

Por que validamos o e-mail com filter_var no PHP, se o campo pode ter type="email" no HTML?

Depois de enviar o formulário (sem PRG), a pessoa aperta F5 na página de "Obrigada". O que acontece?

Qual a ordem certa para mostrar uma mensagem com quebras de linha?

Resumo

  • $dados guarda os campos (com trim e ??); $erros guarda as mensagens com o nome do campo como chave.
  • Valide no servidor: mb_strlen para tamanhos, filter_var(…, FILTER_VALIDATE_EMAIL) para e-mail, lista de valores permitidos para o select.
  • Todo valor devolvido para a página passa por htmlspecialchars (aqui, a função e()): em value, dentro do textarea e na listagem.
  • fputcsv($f, $linha, ',', '"', '') escreve CSV corretamente; JSON com json_encode(…, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) e LOCK_EX.
  • Post/Redirect/Get: depois de salvar, header('Location: …', true, 303); exit;. Nada pode ser impresso antes do header().

Próximo projeto: Login com Sessão, para proteger páginas como o mensagens.php.