INSERT em PHP com PDO: o cadastro que aceita D'Ávila sem abrir brecha de SQL
Troque o INSERT montado com variável por uma consulta preparada com PDO ou mysqli, valide os campos, trate e-mail duplicado e rode com um usuário de banco sem poder de apagar.
O cadastro de clientes da loja funcionou com a Maria, com o João e com mais trezentas pessoas. Aí a Joana D'Ávila tentou se cadastrar e recebeu "You have an error in your SQL syntax". Ninguém tinha mexido no código. Ele sempre esteve quebrado: o apóstrofo do sobrenome só foi a primeira entrada que mostrou isso. A próxima pode não vir de alguém com sobrenome, e sim de alguém procurando a brecha.
A resposta curta: nunca coloque o que veio do formulário dentro do texto do SQL. Escreva o INSERT com marcadores (:nome ou ?), prepare a consulta e envie os valores separados, com PDO ou mysqli. O banco recebe a estrutura da consulta de um lado e os dados do outro, e um apóstrofo volta a ser só uma letra.
Por que concatenar quebra e abre brecha
O código que o tutorial antigo ensinava era mais ou menos este:
$nome = $_POST['nome'];
$email = $_POST['email'];
$sql = "INSERT INTO clientes (nome, email) VALUES ('$nome', '$email')";
mysqli_query($conexao, $sql);
Com "Joana D'Ávila", o texto que chega ao MySQL tem uma aspa a mais. A string do nome termina em "Joana D", e o resto, "Ávila', '...", vira SQL que não faz sentido. Por isso o erro de sintaxe.
Agora imagine que, em vez de um sobrenome, alguém digite uma aspa seguida de uma subconsulta que lê a tabela de usuários e um comentário que anula o resto da linha. O INSERT continua válido e grava, no campo de e-mail do cadastro do atacante, o hash da senha do administrador. Depois basta ele abrir a própria página de perfil. Não precisa de DROP TABLE nem de nada espetacular: injeção em INSERT costuma ser silenciosa e funcionar por meses.
Escapar à mão, com addslashes ou funções parecidas, é tentar adivinhar todos os caracteres perigosos para cada encoding e cada contexto. Consulta preparada elimina a adivinhação, porque o valor nunca é interpretado como SQL.
Como escrever o INSERT com PDO
O exemplo é o cadastro da loja: nome, e-mail e data de nascimento, numa tabela clientes com UNIQUE no e-mail.
- Abra a conexão com charset e modo de erro definidos.
charset=utf8mb4no DSN evita que o "Á" da Joana vire lixo no caminho. Desde o PHP 8, o PDO já lança exceção por padrão, mas deixar explícito protege quem roda versão antiga. - Desligue a emulação de preparo. Com
PDO::ATTR_EMULATE_PREPARESemfalse, o driver do MySQL usa o preparo nativo do servidor em vez de montar a string no PHP. - Valide antes de gravar. Parâmetro protege o SQL, não a regra de negócio. E-mail precisa parecer e-mail, nome precisa ter tamanho máximo, data precisa ser data.
- Liste as colunas no
INSERT.INSERT INTO clientes VALUES (...)sem colunas quebra no dia em que alguém acrescenta um campo na tabela. - Trate a duplicidade como resposta, não como erro 500. O código 1062 do MySQL indica chave única repetida.
<?php
$pdo = new PDO(
'mysql:host=localhost;dbname=loja;charset=utf8mb4',
getenv('DB_USER'),
getenv('DB_PASS'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$nome = trim($_POST['nome'] ?? '');
$email = trim($_POST['email'] ?? '');
$nascimento = $_POST['nascimento'] ?? '';
$erros = [];
if ($nome === '' || mb_strlen($nome) > 120) {
$erros[] = 'Informe um nome com até 120 caracteres.';
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$erros[] = 'Informe um e-mail válido.';
}
$data = DateTime::createFromFormat('!Y-m-d', $nascimento);
if ($data === false || $data->format('Y-m-d') !== $nascimento) {
$erros[] = 'Informe a data de nascimento no formato do campo.';
}
if ($erros) {
http_response_code(422);
exit(implode(' ', $erros));
}
$stmt = $pdo->prepare(
'INSERT INTO clientes (nome, email, nascimento) VALUES (:nome, :email, :nascimento)'
);
try {
$stmt->execute([
'nome' => $nome,
'email' => $email,
'nascimento' => $nascimento,
]);
$id = (int) $pdo->lastInsertId();
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) === 1062) {
http_response_code(409);
exit('Já existe um cadastro com este e-mail.');
}
throw $e;
}
A comparação $data->format('Y-m-d') !== $nascimento existe porque createFromFormat aceita "2026-02-31" e transforma em 3 de março. Comparar a volta com a ida pega a data impossível.
A mesma coisa com mysqli
Se o projeto já usa mysqli, não precisa migrar tudo para PDO para corrigir. O preparo existe lá também, com ? no lugar dos nomes e uma letra de tipo por parâmetro:
$db = new mysqli('localhost', getenv('DB_USER'), getenv('DB_PASS'), 'loja');
$db->set_charset('utf8mb4');
$stmt = $db->prepare('INSERT INTO clientes (nome, email, nascimento) VALUES (?, ?, ?)');
$stmt->bind_param('sss', $nome, $email, $nascimento);
$stmt->execute();
$id = $db->insert_id;
Do PHP 8.2 em diante, $db->execute_query($sql, [$nome, $email, $nascimento]) faz preparo e execução numa chamada só.
O que parâmetro não resolve
Marcador substitui valor. Nome de tabela, nome de coluna e direção de ordenação não podem ser parâmetros. Se a tela deixa escolher a ordenação, compare com uma lista fechada:
$colunas = ['nome' => 'nome', 'cadastro' => 'criado_em'];
$ordem = $colunas[$_GET['ordem'] ?? ''] ?? 'criado_em';
$stmt = $pdo->query("SELECT id, nome FROM clientes ORDER BY $ordem DESC LIMIT 50");
Aqui a variável entra no SQL, mas só pode valer um dos textos que você escreveu.
Parâmetro também não protege a tela. O nome gravado volta para o HTML e precisa de htmlspecialchars na saída, senão um "nome" com <script> vira XSS no painel do atendente. E se o cadastro tiver senha, ela vai com password_hash, como em login com senha protegida. Quem trabalha com Node.js encontra a mesma ideia em Node.js e MySQL com parâmetro.
O usuário do banco também é uma trava
O site não precisa apagar tabelas. Crie um usuário só para ele, com o mínimo:
CREATE USER 'loja_site'@'localhost' IDENTIFIED BY 'troque-por-uma-senha-gerada';
GRANT SELECT, INSERT, UPDATE ON loja.* TO 'loja_site'@'localhost';
Se uma brecha escapar em algum canto antigo do código, ela herda só esses poderes.
Como testar antes de dar por resolvido
Monte uma pequena lista de cadastros de teste e passe todos pelo formulário real, não só pelo código isolado. Na loja do exemplo, a lista tem "Joana D'Ávila", um nome com aspas duplas, um nome com 121 caracteres, um e-mail sem arroba, a data "2026-02-31" e o mesmo e-mail cadastrado duas vezes seguidas.
O resultado esperado é previsível: os dois primeiros gravam e aparecem idênticos na listagem; os três seguintes voltam com a mensagem de validação; o último responde que o e-mail já existe. Se qualquer um deles gerar erro de sintaxe, tela branca ou registro com o nome cortado, ainda há uma consulta montada com variável em algum lugar do caminho. Guarde essa lista num teste automatizado para que a próxima mudança no cadastro passe por ela também.
Erros comuns
- Preparar a consulta já com a variável dentro.
$pdo->prepare("... VALUES ('$nome')")tem a mesma brecha de antes, só com um nome mais bonito. - Escapar com
addslashese achar que resolveu. Funciona até o encoding ou o contexto mudar, e você não fica sabendo quando muda. - Mostrar a mensagem do
PDOExceptionpara o visitante. Ela revela nome de tabela, de coluna e às vezes o usuário do banco. - Tratar e-mail duplicado com um
SELECTantes doINSERT. Dois cadastros simultâneos passam pela checagem; só oUNIQUEno banco garante. - Rodar o site com o usuário
root. Qualquer brecha vira poder de apagar o banco inteiro. - Achar que parâmetro dispensa escapar na saída. O SQL fica seguro e o HTML continua aberto a XSS.
Checklist
- Nenhuma consulta do projeto tem
$_POST,$_GETou variável de formulário dentro do texto do SQL. - A conexão declara
charset=utf8mb4e lança exceção em erro. - Todo
INSERTlista as colunas. - O e-mail tem índice
UNIQUEe o código 1062 vira uma mensagem amigável. - Ordenação e filtros por coluna usam lista fechada.
- O usuário do banco do site não tem
DROP,DELETEem massa nemGRANT. - O cadastro "Joana D'Ávila" grava e aparece igual na tela.
Rode agora uma busca no projeto por VALUES ('$: quantos INSERT ainda colam o formulário direto no SQL?
Referência: http://phpsqlbr.blogspot.com.br/2009/05/inserindo-registros-pelo-php.html