PHP Prepared Statements
A lojinha da Ana ganhou uma caixa de busca: o visitante digita o nome de um produto e o PHP procura no banco. Parece inofensivo, mas, se o texto digitado for colado direto no SQL, o visitante passa a escrever um pedaço do seu SQL. Isso se chama SQL injection ("injeção de SQL") e está há mais de vinte anos entre as falhas de segurança mais exploradas da web. Nesta aula você vai ver o problema acontecer (no seu computador, com dados de mentira), entender por quê, e corrigir com prepared statements ("comandos preparados"). No fim, vai aprender as transações, que garantem que um grupo de comandos aconteça inteiro ou não aconteça.
- O que é SQL injection, vendo uma busca vulnerável funcionar errado.
- Como usar
prepareeexecutecom marcadores?e:nome. - Por que isso resolve o problema (e não é só "tirar as aspas").
- Como usar
bindValueePDO::PARAM_INT. - O que um marcador não pode substituir (nomes de tabela e coluna) e como lidar com isso.
- Como usar
beginTransaction,commiterollBackpara não deixar o banco "pela metade".
Esta aula continua CRUD com PDO. Todos os programas foram executados com o PHP 8.5 na máquina fictícia da Ana, na pasta ~/loja, e você pode repetir cada um no editor do guia.
Tudo o que esta aula mostra roda num banco de mentira, criado na memória do seu próprio computador. Testar SQL injection em sites de outras pessoas, sem autorização por escrito, é crime no Brasil (Lei 12.737/2012, que tipifica a invasão de dispositivo informático) e na maioria dos países. O objetivo aqui é você reconhecer o erro no seu código e nunca cometê-lo.
O banco dos exemplos
Para cada exemplo começar do mesmo jeito, a Ana guardou num arquivo banco.php o código que cria a loja na memória (sqlite::memory:, veja Conectar com PDO). Os outros programas começam com require "banco.php"; (veja include e require) e ganham a variável $pdo pronta.
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:");
$pdo->exec("CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco REAL NOT NULL,
estoque INTEGER NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque, ativo) VALUES
('Caderno', 15.90, 30, 1),
('Caneta azul', 2.50, 100, 1),
('Mochila', 89.90, 5, 1),
('Lápis', 1.20, 200, 1),
('Estojo antigo', 9.90, 0, 0)");
Repare na coluna ativo: o Estojo antigo saiu de linha (ativo = 0) e não deve aparecer para os clientes. Ele vai ser o nosso "dado escondido".
A ideia: o formulário com lacunas
Imagine que a Ana manda um bilhete para o estoquista: "Traga o produto ________." e pede ao cliente que complete a lacuna. Se o cliente escreve Caderno, tudo bem. Mas e se ele escreve Caderno. E também abra o cofre? Se o estoquista lê o bilhete inteiro como uma ordem só, ele abre o cofre, porque não dá para saber onde termina o texto do cliente e onde recomeça a ordem da Ana.
Agora imagine um formulário impresso, em que o nome do produto vai num campo cercado. O que estiver dentro do campo é sempre o nome de um produto, nunca uma ordem, mesmo que seja a frase "abra o cofre". Esse formulário é o prepared statement: o SQL vai com lacunas marcadas (?), e os valores vão separados, para dentro das lacunas.
1. A busca vulnerável
Este é o jeito errado, que você ainda vai encontrar em muito tutorial antigo: montar o SQL juntando textos. Para facilitar o teste no terminal, o termo buscado vem de $argv[1], o primeiro argumento do comando (veja PHP na Linha de Comando); num site, ele viria de $_GET["busca"] (veja $_GET e $_POST). O programa mostra o SQL montado antes de rodá-lo, para você ver o que o banco recebe:
<?php
require "banco.php";
$busca = $argv[1]; // o que o visitante digitou na caixa de busca
$sql = "SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = '$busca'";
echo "SQL enviado: $sql\n";
foreach ($pdo->query($sql) as $p) {
echo "- ", $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
}
Com uma busca normal, funciona:
$ php busca.php Caderno SQL enviado: SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = 'Caderno' - Caderno: R$ 15,90
Primeiro sintoma: um apóstrofo quebra tudo
Um cliente procura por copo d'água:
$ php busca.php "copo d'água"
SQL enviado: SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = 'copo d'água'
Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 near "água": syntax error in /home/ana/loja/busca.php:9
Stack trace:
#0 /home/ana/loja/busca.php(9): PDO->query('SELECT nome, pr...')
#1 {main}
thrown in /home/ana/loja/busca.php on line 9
Olhe a linha SQL enviado: o apóstrofo de d'água fechou a aspa que a Ana abriu em nome = '. Para o SQLite, o texto acabou em 'copo d' e veio em seguida uma palavra solta, água, que não é SQL: near "água": syntax error ("erro de sintaxe perto de água"). O texto do visitante virou parte do comando. Isso é um erro de programação, mesmo que ninguém tenha tido má intenção.
O ataque: o visitante reescreve o WHERE
Se um apóstrofo fecha a aspa, alguém mal-intencionado pode fechar a aspa de propósito e escrever o resto. Este é o exemplo clássico, ensinado em todo curso de segurança:
$ php busca.php "x' OR '1'='1" SQL enviado: SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = 'x' OR '1'='1' - Caderno: R$ 15,90 - Caneta azul: R$ 2,50 - Mochila: R$ 89,90 - Lápis: R$ 1,20 - Estojo antigo: R$ 9,90 « o produto escondido apareceu!
Leia o SQL enviado com calma. O visitante digitou x' OR '1'='1, e o SQL ficou:
| Pedaço | Quem escreveu | O que significa |
|---|---|---|
WHERE ativo = 1 AND nome = ' | Ana | Início da condição e a aspa que abre o texto. |
x' | Visitante | Um nome qualquer e a aspa que fecha o texto antes da hora. |
OR '1'='1 | Visitante | "OU 1 é igual a 1": uma condição que é sempre verdadeira. |
' | Ana | A aspa final da Ana, que agora fecha o '1 do visitante. |
No SQL, o AND é feito antes do OR, então a condição virou (ativo = 1 AND nome = 'x') OR '1'='1': "ou é um produto ativo chamado x, ou 1 é igual a 1". Como 1 é sempre igual a 1, todas as linhas passam, inclusive o produto escondido. Numa tela de login, a mesma técnica pode entrar sem senha; com outras variações, pode ler tabelas que nunca deveriam aparecer (clientes, senhas) e, em alguns bancos, alterar ou apagar dados.
A tentação é "tirar os apóstrofos" do texto, ou trocar ' por \' com addslashes. Não faça isso: cada banco tem regras diferentes para escapar texto, há truques com outros caracteres e codificações, e basta esquecer em um lugar. A solução de verdade é nunca colar dados no meio do SQL.
2. A solução: prepare + execute com ?
A versão segura troca o valor por um marcador (placeholder), o ?, e manda o valor separado:
<?php
require "banco.php";
$busca = $argv[1];
$stmt = $pdo->prepare("SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = ?");
$stmt->execute([$busca]);
$achou = false;
foreach ($stmt as $p) {
echo "- ", $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
$achou = true;
}
if (!$achou) {
echo "Nenhum produto encontrado.\n";
}
- $pdo->prepare("… nome = ?")
- Prepara o comando: envia ao banco o SQL com a lacuna. O banco analisa e "compila" o SQL agora, antes de ver qualquer valor. Devolve um objeto
PDOStatement(um "comando preparado"), guardado em$stmt(abreviação comum de statement, "comando"). - ?
- A lacuna. Sem aspas em volta: o banco já sabe que ali vai um valor.
- $stmt->execute([$busca])
- Executa o comando preparado, mandando os valores num array, na ordem dos
?. O valor vai como dado puro: o banco nunca o lê como SQL. - foreach ($stmt as $p)
- O próprio
$stmtentrega as linhas, uma por vez (também dá para usarfetchefetchAll, como em Conectar com PDO).
As mesmas três buscas:
$ php busca-segura.php Caderno - Caderno: R$ 15,90 $ php busca-segura.php "x' OR '1'='1" Nenhum produto encontrado. « procurou um produto com esse nome esquisito: não existe $ php busca-segura.php "copo d'água" Nenhum produto encontrado. « nada de erro de sintaxe
O texto x' OR '1'='1 foi tratado exatamente como o que ele é: o nome de um produto, com apóstrofos e tudo. Como não existe produto com esse nome, a busca não achou nada. E o copo d'água deixou de quebrar o programa.
Por que isso resolve
Com o SQL montado por colagem, o banco recebe uma coisa só e precisa adivinhar onde termina o seu código e onde começa o texto do visitante. Com o prepared statement, ele recebe duas coisas separadas:
| Colando texto (vulnerável) | Prepared statement | |
|---|---|---|
| O que vai para o banco | … nome = 'x' OR '1'='1' (tudo junto) | 1º: … nome = ?2º: o valor x' OR '1'='1 |
| Quando o SQL é analisado | Depois que o valor já está dentro | Antes de o valor chegar |
| O valor pode virar código? | Sim | Não: a "forma" do comando já estava decidida |
Apóstrofos, acentos, ;, -- | Quebram ou mudam o comando | São só letras do valor |
Dá para ver essa separação com $stmt->debugDumpParams(), que mostra o que o comando preparado guardou:
<?php
require "banco.php";
$stmt = $pdo->prepare("SELECT nome FROM produtos WHERE nome = ?");
$stmt->execute(["x' OR '1'='1"]);
$stmt->debugDumpParams();
$ php raio-x.php SQL: [40] SELECT nome FROM produtos WHERE nome = ? « o SQL continua com a lacuna Params: 1 « e há 1 valor, guardado à parte Key: Position #0: paramno=0 name=[0] "" is_param=1 param_type=2 « 2 = PDO::PARAM_STR (texto)
O SQL guardado tem o ?, nunca o texto do visitante. O valor fica na "posição 0" (o primeiro ?), marcado como texto (param_type=2, que é o valor da constante PDO::PARAM_STR).
Missão: a função buscar está vulnerável: rode e veja a "Busca 2" mostrar todos os produtos, inclusive o Estojo antigo. Corrija-a com prepare e execute, usando um ? no lugar de '$busca'. Depois da correção, a Busca 1 continua achando o Caderno e a Busca 2 mostra Nenhum produto encontrado.
<?php
require "banco.php";
function buscar(PDO $pdo, string $busca): void
{
$sql = "SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = '$busca'";
$achou = false;
foreach ($pdo->query($sql) as $p) {
echo "- ", $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
$achou = true;
}
if (!$achou) {
echo "Nenhum produto encontrado.\n";
}
}
echo "Busca 1:\n";
buscar($pdo, "Caderno");
echo "Busca 2:\n";
buscar($pdo, "x' OR '1'='1");
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:");
$pdo->exec("CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco REAL NOT NULL,
estoque INTEGER NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque, ativo) VALUES
('Caderno', 15.90, 30, 1),
('Caneta azul', 2.50, 100, 1),
('Mochila', 89.90, 5, 1),
('Lápis', 1.20, 200, 1),
('Estojo antigo', 9.90, 0, 0)");
<?php
require "banco.php";
function buscar(PDO $pdo, string $busca): void
{
$stmt = $pdo->prepare("SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = ?");
$stmt->execute([$busca]);
$achou = false;
foreach ($stmt as $p) {
echo "- ", $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
$achou = true;
}
if (!$achou) {
echo "Nenhum produto encontrado.\n";
}
}
echo "Busca 1:\n";
buscar($pdo, "Caderno");
echo "Busca 2:\n";
buscar($pdo, "x' OR '1'='1");
3. Marcadores com nome: :nome
Com vários valores, contar ? fica confuso. A outra forma de marcador tem nome: dois-pontos seguidos de uma palavra. Os valores vão num array associativo, cada chave com o nome do marcador:
<?php
require "banco.php";
$stmt = $pdo->prepare(
"SELECT nome, preco FROM produtos
WHERE ativo = 1 AND preco BETWEEN :minimo AND :maximo
ORDER BY preco"
);
$stmt->execute(["minimo" => 2, "maximo" => 20]);
foreach ($stmt as $p) {
echo "- ", $p["nome"], ": R$ ", number_format($p["preco"], 2, ",", "."), "\n";
}
$ php faixa.php - Caneta azul: R$ 2,50 - Caderno: R$ 15,90
- :minimo, :maximo
- Duas lacunas com nome. A ordem em que aparecem no SQL não importa mais.
- ["minimo" => 2, "maximo" => 20]
- Os valores, pelo nome. A chave pode ser escrita com ou sem os dois-pontos (
":minimo"também funciona). - BETWEEN … AND …
- SQL para "entre … e …", incluindo as pontas.
? (posicional) | :nome (nomeado) | |
|---|---|---|
| Valores | Array comum, na ordem dos ? | Array associativo, pelo nome |
| Bom para | Um ou dois valores | Muitos valores; SQL longo; o mesmo valor usado duas vezes |
| Exemplo | execute([$nome, $preco]) | execute(["nome" => $nome, "preco" => $preco]) |
Escolha um estilo por comando: não misture ? e :nome no mesmo SQL.
Missão: os números 5 e 100 estão escritos direto no SQL. Troque-os pelos marcadores :minimo e :maximo e passe os valores no execute. A saída deve continuar:
Mochila: 5 Caderno: 30 Caneta azul: 100
<?php
require "banco.php";
$stmt = $pdo->prepare(
"SELECT nome, estoque FROM produtos
WHERE estoque BETWEEN 5 AND 100
ORDER BY estoque"
);
$stmt->execute();
foreach ($stmt as $p) {
echo $p["nome"], ": ", $p["estoque"], "\n";
}
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:");
$pdo->exec("CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco REAL NOT NULL,
estoque INTEGER NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque, ativo) VALUES
('Caderno', 15.90, 30, 1),
('Caneta azul', 2.50, 100, 1),
('Mochila', 89.90, 5, 1),
('Lápis', 1.20, 200, 1),
('Estojo antigo', 9.90, 0, 0)");
<?php
require "banco.php";
$stmt = $pdo->prepare(
"SELECT nome, estoque FROM produtos
WHERE estoque BETWEEN :minimo AND :maximo
ORDER BY estoque"
);
$stmt->execute(["minimo" => 5, "maximo" => 100]);
foreach ($stmt as $p) {
echo $p["nome"], ": ", $p["estoque"], "\n";
}
4. Buscar "contém": LIKE com marcador
Para achar produtos que contêm um pedaço de texto, o SQL usa LIKE com % ("qualquer coisa"). O % faz parte do valor, não do SQL: monte-o no PHP e deixe o ? sozinho:
<?php
require "banco.php";
$termo = $argv[1];
$stmt = $pdo->prepare("SELECT nome FROM produtos WHERE nome LIKE ?");
$stmt->execute(["%$termo%"]);
foreach ($stmt as $p) {
echo "- ", $p["nome"], "\n";
}
$ php contem.php an - Caneta azul « "an" no meio de "Caneta" - Estojo antigo « "an" no começo de "antigo"
Escrever LIKE '%?%' não funciona: entre aspas, o ? é só um ponto de interrogação (veja o cenário abaixo).
5. bindValue: dizer o tipo do valor
O execute([...]) manda todos os valores como texto. Quase sempre o banco converte sozinho, mas às vezes você quer dizer o tipo com todas as letras, por exemplo no LIMIT, que precisa de um número inteiro (o MySQL recusa LIMIT '2', com aspas). Para isso existe o bindValue ("ligar valor"):
<?php
require "banco.php";
$quantos = $argv[1]; // vem como texto: "2"
$stmt = $pdo->prepare("SELECT nome, preco FROM produtos ORDER BY preco DESC LIMIT :quantos");
$stmt->bindValue(":quantos", (int) $quantos, PDO::PARAM_INT);
$stmt->execute();
foreach ($stmt as $p) {
echo "- ", $p["nome"], "\n";
}
$ php mais-caros.php 2 - Mochila - Caderno
- bindValue(":quantos", …)
- Põe um valor numa lacuna. Com
?, a lacuna é um número:bindValue(1, …)para o primeiro?(aqui começa em 1, não em 0). - (int) $quantos
- Converte o texto
"2"em número inteiro (veja Conversão de Tipos). - PDO::PARAM_INT
- Avisa ao banco: "é um inteiro". Outras:
PDO::PARAM_STR(texto, o padrão),PDO::PARAM_BOOL,PDO::PARAM_NULL. - execute()
- Sem argumentos: os valores já foram ligados.
bindValue × bindParam
Existe também o bindParam, que liga a lacuna a uma variável (não ao valor dela naquele momento): o valor é lido só na hora do execute. É útil em laços, mas confunde quem está começando. Prefira execute([...]) no dia a dia e bindValue quando precisar do tipo.
Preparar uma vez, executar várias
Um comando preparado pode ser executado quantas vezes você quiser, com valores diferentes. O banco analisa o SQL só uma vez:
<?php
require "banco.php";
$novos = [
["Borracha", 0.90, 50],
["Régua 30 cm", 4.50, 20],
["Cola bastão", 6.75, 15],
];
$stmt = $pdo->prepare("INSERT INTO produtos (nome, preco, estoque) VALUES (?, ?, ?)");
foreach ($novos as $produto) {
$stmt->execute($produto);
echo "Cadastrado com id ", $pdo->lastInsertId(), ": ", $produto[0], "\n";
}
$ php cadastrar-varios.php Cadastrado com id 6: Borracha Cadastrado com id 7: Régua 30 cm Cadastrado com id 8: Cola bastão
Cada $produto já é um array com três valores, na ordem dos três ?, então vai direto para o execute. O lastInsertId() está explicado em CRUD com PDO.
6. O que um marcador não pode substituir
Um marcador é sempre um valor: um texto, um número, uma data. Ele não pode ser um pedaço da estrutura do SQL: nome de tabela, nome de coluna, ASC/DESC, palavras do SQL.
<?php
require "banco.php";
$stmt = $pdo->prepare("SELECT * FROM ?");
$ php tabela-marcador.php
Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 near "?": syntax error in /home/ana/loja/tabela-marcador.php:4
Stack trace:
#0 /home/ana/loja/tabela-marcador.php(4): PDO->prepare('SELECT * FROM ?')
#1 {main}
thrown in /home/ana/loja/tabela-marcador.php on line 4
Pior é quando não dá erro. A Ana quis deixar o cliente escolher a ordem da lista:
<?php
require "banco.php";
$coluna = $argv[1]; // o visitante escolheu ordenar por "preco"
$stmt = $pdo->prepare("SELECT nome, preco FROM produtos WHERE ativo = 1 ORDER BY ?");
$stmt->execute([$coluna]);
foreach ($stmt as $p) {
echo "- ", $p["nome"], ": ", $p["preco"], "\n";
}
$ php ordem.php preco - Caderno: 15.9 « não ordenou pelo preço! - Caneta azul: 2.5 - Mochila: 89.9 - Lápis: 1.2
O banco ordenou pelo texto fixo 'preco', que é igual em todas as linhas, ou seja, não ordenou nada. Para escolher colunas, use uma lista de permitidas (whitelist): o visitante escolhe, mas só vale o que está na sua lista.
<?php
require "banco.php";
$permitidas = ["nome", "preco", "estoque"];
$coluna = $argv[1];
if (!in_array($coluna, $permitidas, true)) {
$coluna = "nome"; // valor estranho? usa o padrão
}
$stmt = $pdo->prepare("SELECT nome, preco FROM produtos WHERE ativo = 1 ORDER BY $coluna");
$stmt->execute();
foreach ($stmt as $p) {
echo "- ", $p["nome"], ": ", $p["preco"], "\n";
}
$ php ordem-segura.php preco - Lápis: 1.2 - Caneta azul: 2.5 - Caderno: 15.9 - Mochila: 89.9 $ php ordem-segura.php "preco; DROP TABLE produtos" - Caderno: 15.9 « não estava na lista: ordenou por nome - Caneta azul: 2.5 - Lápis: 1.2 - Mochila: 89.9
Aqui a coluna é colada no SQL, mas isso é seguro porque $coluna só pode ser uma das três palavras que a Ana escreveu. O in_array(…, true) compara de forma estrita (veja Funções de Array).
7. Outras formas (e qual usar)
| Forma | Exemplo | Segura? | Quando usar |
|---|---|---|---|
| Colar o valor no SQL | "… nome = '$busca'" | Não | Nunca com dados que vêm de fora |
addslashes / tirar aspas na mão | "… '" . addslashes($busca) . "'" | Não (depende do banco e da codificação) | Nunca |
$pdo->quote() | "… nome = " . $pdo->quote($busca) | Sim, se usada sempre | Raramente: só onde não dá para preparar |
prepare + execute([...]) | "… nome = ?" | Sim | Sempre que houver um valor variável |
prepare + bindValue | bindValue(":qtd", $n, PDO::PARAM_INT) | Sim | Quando o tipo importa (LIMIT, NULL) |
| Lista de permitidas | in_array($coluna, $permitidas, true) | Sim | Nomes de coluna, ASC/DESC |
query/exec com SQL fixo | $pdo->query("SELECT COUNT(*) FROM produtos") | Sim (não tem valor de fora) | SQL sem nenhuma variável |
Só para conhecer, o quote põe aspas e dobra os apóstrofos, do jeito que este banco espera:
<?php
require "banco.php";
echo $pdo->quote("Caderno"), "\n";
echo $pdo->quote("copo d'água"), "\n";
echo $pdo->quote("x' OR '1'='1"), "\n";
$ php quote.php 'Caderno' 'copo d''água' 'x'' OR ''1''=''1'
Funciona, mas depende de você lembrar de usar em todo lugar. O prepared statement é seguro por construção. Regra prática: se o SQL tem uma variável PHP dentro, ele deveria ter um ? (a exceção é a lista de permitidas).
htmlspecialchars?
É outro problema, em outro lugar. O prepared statement protege o banco na hora de gravar e buscar. O htmlspecialchars protege a página na hora de mostrar o que veio do banco (o ataque XSS). Um site seguro usa os dois. Veja Escapar a Saída (XSS).
8. Transações: tudo ou nada
Uma venda na lojinha são dois comandos: diminuir o estoque (UPDATE) e registrar a venda (INSERT). E se o primeiro acontecer e o segundo não? O estoque diminui, mas a venda não existe. O banco fica "pela metade".
Pense num caixa eletrônico fazendo uma transferência: tirar da sua conta e pôr na outra. Se a energia cai no meio, o banco precisa garantir que o dinheiro não suma. Uma transação faz isso: junta vários comandos num pacote que acontece inteiro ou é desfeito inteiro.
O problema, sem transação
Este programa vende 2 mochilas (tem 5) e depois tenta vender 10. A conferência de estoque acontece depois do UPDATE:
<?php
require "banco.php";
$pdo->exec("CREATE TABLE vendas (id INTEGER PRIMARY KEY, produto_id INTEGER NOT NULL, quantidade INTEGER NOT NULL)");
function vender(PDO $pdo, int $produtoId, int $quantidade): void
{
try {
$baixa = $pdo->prepare("UPDATE produtos SET estoque = estoque - ? WHERE id = ?");
$baixa->execute([$quantidade, $produtoId]);
$consulta = $pdo->prepare("SELECT estoque FROM produtos WHERE id = ?");
$consulta->execute([$produtoId]);
if ($consulta->fetchColumn() < 0) {
throw new RuntimeException("estoque insuficiente");
}
$registro = $pdo->prepare("INSERT INTO vendas (produto_id, quantidade) VALUES (?, ?)");
$registro->execute([$produtoId, $quantidade]);
echo "Venda registrada: $quantidade unidade(s).\n";
} catch (Exception $e) {
echo "Venda cancelada: ", $e->getMessage(), ".\n";
}
}
vender($pdo, 3, 2); // 2 mochilas
vender($pdo, 3, 10); // 10 mochilas: não tem!
echo "Estoque da Mochila: ", $pdo->query("SELECT estoque FROM produtos WHERE id = 3")->fetchColumn(), "\n";
echo "Vendas registradas: ", $pdo->query("SELECT COUNT(*) FROM vendas")->fetchColumn(), "\n";
$ php venda-sem-transacao.php Venda registrada: 2 unidade(s). Venda cancelada: estoque insuficiente. Estoque da Mochila: -7 « 5 - 2 - 10: o UPDATE da venda cancelada ficou! Vendas registradas: 1
A segunda venda foi "cancelada", mas o UPDATE já tinha acontecido: o estoque ficou em −7. O throw (veja Exceções) parou o programa antes do INSERT, mas não desfez o que veio antes.
A solução: beginTransaction, commit, rollBack
<?php
require "banco.php";
$pdo->exec("CREATE TABLE vendas (id INTEGER PRIMARY KEY, produto_id INTEGER NOT NULL, quantidade INTEGER NOT NULL)");
function vender(PDO $pdo, int $produtoId, int $quantidade): void
{
$pdo->beginTransaction();
try {
$baixa = $pdo->prepare("UPDATE produtos SET estoque = estoque - ? WHERE id = ?");
$baixa->execute([$quantidade, $produtoId]);
$consulta = $pdo->prepare("SELECT estoque FROM produtos WHERE id = ?");
$consulta->execute([$produtoId]);
if ($consulta->fetchColumn() < 0) {
throw new RuntimeException("estoque insuficiente");
}
$registro = $pdo->prepare("INSERT INTO vendas (produto_id, quantidade) VALUES (?, ?)");
$registro->execute([$produtoId, $quantidade]);
$pdo->commit();
echo "Venda registrada: $quantidade unidade(s).\n";
} catch (Exception $e) {
$pdo->rollBack();
echo "Venda cancelada: ", $e->getMessage(), ".\n";
}
}
vender($pdo, 3, 2); // 2 mochilas
vender($pdo, 3, 10); // 10 mochilas: não tem!
echo "Estoque da Mochila: ", $pdo->query("SELECT estoque FROM produtos WHERE id = 3")->fetchColumn(), "\n";
echo "Vendas registradas: ", $pdo->query("SELECT COUNT(*) FROM vendas")->fetchColumn(), "\n";
$ php venda.php Venda registrada: 2 unidade(s). Venda cancelada: estoque insuficiente. Estoque da Mochila: 3 « 5 - 2: a venda cancelada foi desfeita por inteiro Vendas registradas: 1
- $pdo->beginTransaction()
- "Comece um pacote." A partir daqui, as mudanças ficam provisórias: o próprio programa as enxerga, mas elas ainda não são definitivas.
- $pdo->commit()
- "Confirme o pacote." Todas as mudanças viram definitivas de uma vez. Fica no fim do
try, depois do último comando. - $pdo->rollBack()
- "Desfaça o pacote." Tudo desde o
beginTransactionvolta ao que era (roll back = "rolar de volta"). Fica nocatch: qualquer erro, de SQL ou seu, desfaz tudo.
Se o programa terminar (ou travar) sem commit, o PDO também desfaz a transação sozinho. Repare ainda que o B de rollBack é maiúsculo: o PHP não diferencia maiúsculas em nomes de métodos, mas é assim que a documentação escreve.
| Use transação quando… | Exemplo |
|---|---|
| Vários comandos precisam acontecer juntos | Venda (estoque + registro), transferência (sai de uma conta + entra na outra) |
| Você grava muitas linhas de uma vez | Importar 1.000 produtos de um CSV: tudo ou nada (e, no SQLite, muito mais rápido) |
| Não precisa | Um único INSERT, UPDATE ou DELETE sozinho: ele já é "tudo ou nada" por si |
Quando dá errado
near "…": syntax error)
$ php busca.php "copo d'água"
SQL enviado: SELECT nome, preco FROM produtos WHERE ativo = 1 AND nome = 'copo d'água'
Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 near "água": syntax error in /home/ana/loja/busca.php:9
Stack trace:
#0 /home/ana/loja/busca.php(9): PDO->query('SELECT nome, pr...')
#1 {main}
thrown in /home/ana/loja/busca.php on line 9
O que significa: um valor com apóstrofo foi colado no SQL e quebrou o comando. Esse erro é o aviso de que o código está vulnerável a SQL injection.
Como consertar: troque '$variavel' por ?, query por prepare e passe o valor no execute([...]).
General error: 25 column index out of range
<?php
require "banco.php";
$stmt = $pdo->prepare("SELECT nome FROM produtos WHERE nome LIKE '%?%'");
$stmt->execute(["an"]);
$ php aspas-no-marcador.php
Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 25 column index out of range in /home/ana/loja/aspas-no-marcador.php:5
Stack trace:
#0 /home/ana/loja/aspas-no-marcador.php(5): PDOStatement->execute(Array)
#1 {main}
thrown in /home/ana/loja/aspas-no-marcador.php on line 5
O que significa: "índice fora do intervalo": você mandou mais valores do que lacunas. Aqui, o ? está entre aspas, então é só um caractere de texto, e o SQL ficou com zero lacunas para um valor. A mesma mensagem aparece com execute([20, 5]) para um SQL com um só ?.
Como consertar: marcador sempre sem aspas (LIKE ?), e o % vai no valor: execute(["%$termo%"]). Conte os ? e os valores.
<?php
require "banco.php";
$stmt = $pdo->prepare("SELECT nome FROM produtos WHERE preco BETWEEN :minimo AND :maximo");
$stmt->execute(["minimo" => 2]); // esqueceu o :maximo
print_r($stmt->fetchAll(PDO::FETCH_COLUMN));
$ php poucos.php Array ( )
O que significa: com o SQLite, uma lacuna sem valor vira NULL, e nenhuma comparação com NULL é verdadeira: a lista volta vazia, em silêncio. (Em outros bancos, como o MySQL, o PDO costuma recusar com um erro HY093, "número de parâmetros inválido".) O mesmo acontece se a chave estiver com o nome errado ("max" em vez de "maximo").
Como consertar: confira se cada :nome do SQL tem uma chave com o mesmo nome no array.
There is no active transaction
<?php
require "banco.php";
$pdo->exec("UPDATE produtos SET estoque = estoque - 1 WHERE id = 3");
$pdo->commit();
$ php sem-begin.php
Fatal error: Uncaught PDOException: There is no active transaction in /home/ana/loja/sem-begin.php:5
Stack trace:
#0 /home/ana/loja/sem-begin.php(5): PDO->commit()
#1 {main}
thrown in /home/ana/loja/sem-begin.php on line 5
O que significa: "não há transação ativa": um commit() (ou rollBack()) sem beginTransaction() antes. Também acontece quando o commit é chamado duas vezes. O contrário, chamar beginTransaction() com uma transação já aberta, dá There is already an active transaction.
Como consertar: um beginTransaction antes do try, um commit no fim do try, um rollBack no catch. Na dúvida, $pdo->inTransaction() diz se há uma aberta.
commit)
<?php
$pdo = new PDO("sqlite:estoque.db");
$pdo->exec("CREATE TABLE IF NOT EXISTS contador (valor INTEGER)");
$pdo->exec("DELETE FROM contador");
$pdo->exec("INSERT INTO contador VALUES (1)");
$pdo->beginTransaction();
$pdo->exec("UPDATE contador SET valor = 99");
echo "Dentro da transação: ", $pdo->query("SELECT valor FROM contador")->fetchColumn(), "\n";
// ... e o programa termina sem commit()
<?php
$pdo = new PDO("sqlite:estoque.db");
echo "Depois: ", $pdo->query("SELECT valor FROM contador")->fetchColumn(), "\n";
$ php esqueceu-commit.php Dentro da transação: 99 $ php confere.php Depois: 1
O que significa: dentro da transação, o programa via o 99. Mas ele terminou sem commit(), e o PDO desfez tudo. Outro programa, abrindo o mesmo arquivo estoque.db, encontra o valor antigo. Sem mensagem nenhuma.
Como consertar: todo beginTransaction() precisa de um commit() no caminho em que deu tudo certo.
Missão: rode e veja o estoque da Mochila terminar em -7. Coloque a venda dentro de uma transação: beginTransaction() antes do try, commit() depois do INSERT e rollBack() no catch. O resultado deve ser:
Venda registrada: 2 unidade(s). Venda cancelada: estoque insuficiente. Estoque da Mochila: 3 Vendas registradas: 1
<?php
require "banco.php";
$pdo->exec("CREATE TABLE vendas (id INTEGER PRIMARY KEY, produto_id INTEGER NOT NULL, quantidade INTEGER NOT NULL)");
function vender(PDO $pdo, int $produtoId, int $quantidade): void
{
try {
$baixa = $pdo->prepare("UPDATE produtos SET estoque = estoque - ? WHERE id = ?");
$baixa->execute([$quantidade, $produtoId]);
$consulta = $pdo->prepare("SELECT estoque FROM produtos WHERE id = ?");
$consulta->execute([$produtoId]);
if ($consulta->fetchColumn() < 0) {
throw new RuntimeException("estoque insuficiente");
}
$registro = $pdo->prepare("INSERT INTO vendas (produto_id, quantidade) VALUES (?, ?)");
$registro->execute([$produtoId, $quantidade]);
echo "Venda registrada: $quantidade unidade(s).\n";
} catch (Exception $e) {
echo "Venda cancelada: ", $e->getMessage(), ".\n";
}
}
vender($pdo, 3, 2); // 2 mochilas
vender($pdo, 3, 10); // 10 mochilas: não tem!
echo "Estoque da Mochila: ", $pdo->query("SELECT estoque FROM produtos WHERE id = 3")->fetchColumn(), "\n";
echo "Vendas registradas: ", $pdo->query("SELECT COUNT(*) FROM vendas")->fetchColumn(), "\n";
<?php
// Cria a lojinha da Ana na memória, com 5 produtos.
$pdo = new PDO("sqlite::memory:");
$pdo->exec("CREATE TABLE produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco REAL NOT NULL,
estoque INTEGER NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
)");
$pdo->exec("INSERT INTO produtos (nome, preco, estoque, ativo) VALUES
('Caderno', 15.90, 30, 1),
('Caneta azul', 2.50, 100, 1),
('Mochila', 89.90, 5, 1),
('Lápis', 1.20, 200, 1),
('Estojo antigo', 9.90, 0, 0)");
<?php
require "banco.php";
$pdo->exec("CREATE TABLE vendas (id INTEGER PRIMARY KEY, produto_id INTEGER NOT NULL, quantidade INTEGER NOT NULL)");
function vender(PDO $pdo, int $produtoId, int $quantidade): void
{
$pdo->beginTransaction();
try {
$baixa = $pdo->prepare("UPDATE produtos SET estoque = estoque - ? WHERE id = ?");
$baixa->execute([$quantidade, $produtoId]);
$consulta = $pdo->prepare("SELECT estoque FROM produtos WHERE id = ?");
$consulta->execute([$produtoId]);
if ($consulta->fetchColumn() < 0) {
throw new RuntimeException("estoque insuficiente");
}
$registro = $pdo->prepare("INSERT INTO vendas (produto_id, quantidade) VALUES (?, ?)");
$registro->execute([$produtoId, $quantidade]);
$pdo->commit();
echo "Venda registrada: $quantidade unidade(s).\n";
} catch (Exception $e) {
$pdo->rollBack();
echo "Venda cancelada: ", $e->getMessage(), ".\n";
}
}
vender($pdo, 3, 2); // 2 mochilas
vender($pdo, 3, 10); // 10 mochilas: não tem!
echo "Estoque da Mochila: ", $pdo->query("SELECT estoque FROM produtos WHERE id = 3")->fetchColumn(), "\n";
echo "Vendas registradas: ", $pdo->query("SELECT COUNT(*) FROM vendas")->fetchColumn(), "\n";
Referência
| Método | O que faz | Exemplo |
|---|---|---|
$pdo->prepare($sql) | Prepara um SQL com lacunas; devolve um PDOStatement | $pdo->prepare("… WHERE id = ?") |
$stmt->execute([...]) | Executa com os valores (posicionais ou por nome); devolve true | execute([$id]), execute(["id" => $id]) |
$stmt->bindValue($lacuna, $valor, $tipo) | Liga um valor a uma lacuna, com tipo | bindValue(":qtd", 5, PDO::PARAM_INT) |
$stmt->bindParam($lacuna, $variavel) | Liga uma variável, lida no execute | bindParam(1, $nome) |
$stmt->fetch() / fetchAll() / fetchColumn() | Lê o resultado | veja Conectar com PDO |
$stmt->rowCount() | Linhas afetadas pelo último execute | veja CRUD com PDO |
$stmt->debugDumpParams() | Mostra o SQL preparado e os valores ligados | para depurar |
$pdo->quote($texto) | Põe aspas e escapa para este banco | 'copo d''água' |
$pdo->beginTransaction() | Abre uma transação | antes do try |
$pdo->commit() | Confirma tudo desde o beginTransaction | no fim do try |
$pdo->rollBack() | Desfaz tudo desde o beginTransaction | no catch |
$pdo->inTransaction() | Há uma transação aberta? | true/false |
PDO::PARAM_STR, PARAM_INT, PARAM_BOOL, PARAM_NULL | Tipos para bindValue |
Exercícios
1. A lacuna
Complete o SQL para buscar o produto pelo id com um marcador posicional:
2. Os valores
Complete para executar o comando "… WHERE nome = :nome" com o valor da variável $nome:
3. Desfazer
Complete o catch para desfazer a transação:
4. Qual destas linhas é segura contra SQL injection?
5. Por que o prepared statement impede a injeção?
6. A Ana quer deixar o cliente escolher a coluna do ORDER BY. O que fazer?
7. Numa transação, o segundo de três comandos lança uma exceção e o catch chama rollBack(). O que fica gravado?
Resumo
- SQL injection: quando um valor de fora é colado no SQL, quem digitou passa a escrever um pedaço do seu comando (
x' OR '1'='1mostrou até o produto escondido). - O primeiro sintoma costuma ser um apóstrofo (
copo d'água) dandosyntax error. - A solução:
$stmt = $pdo->prepare("… = ?")e$stmt->execute([$valor]). O SQL é analisado antes; o valor vai separado e nunca vira código. - Marcadores:
?(na ordem) ou:nome(por nome), sem aspas. NoLIKE, o%vai no valor. bindValue(…, PDO::PARAM_INT)quando o tipo importa, como noLIMIT.- Marcador é só para valores. Nomes de coluna e tabela: lista de permitidas.
- Nunca
addslashesnem "tirar aspas na mão". Ohtmlspecialcharsé para a saída, não para o banco. - Transação:
beginTransaction()→ comandos →commit(); nocatch,rollBack(). Tudo ou nada.
Próxima aula: MySQL e MariaDB, para levar tudo isso para um servidor de banco de dados.