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.

Você vai aprender
  • O que é SQL injection, vendo uma busca vulnerável funcionar errado.
  • Como usar prepare e execute com marcadores ? e :nome.
  • Por que isso resolve o problema (e não é só "tirar as aspas").
  • Como usar bindValue e PDO::PARAM_INT.
  • O que um marcador não pode substituir (nomes de tabela e coluna) e como lidar com isso.
  • Como usar beginTransaction, commit e rollBack para 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.

Só no que é seu

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çoQuem escreveuO que significa
WHERE ativo = 1 AND nome = 'AnaInício da condição e a aspa que abre o texto.
x'VisitanteUm nome qualquer e a aspa que fecha o texto antes da hora.
OR '1'='1Visitante"OU 1 é igual a 1": uma condição que é sempre verdadeira.
'AnaA 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.

O problema não é o apóstrofo; é misturar código com 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 $stmt entrega as linhas, uma por vez (também dá para usar fetch e fetchAll, 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 é analisadoDepois que o valor já está dentroAntes de o valor chegar
O valor pode virar código?SimNão: a "forma" do comando já estava decidida
Apóstrofos, acentos, ;, --Quebram ou mudam o comandoSã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)
ValoresArray comum, na ordem dos ?Array associativo, pelo nome
Bom paraUm ou dois valoresMuitos valores; SQL longo; o mesmo valor usado duas vezes
Exemploexecute([$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)

FormaExemploSegura?Quando usar
Colar o valor no SQL"… nome = '$busca'"NãoNunca 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 sempreRaramente: só onde não dá para preparar
prepare + execute([...])"… nome = ?"SimSempre que houver um valor variável
prepare + bindValuebindValue(":qtd", $n, PDO::PARAM_INT)SimQuando o tipo importa (LIMIT, NULL)
Lista de permitidasin_array($coluna, $permitidas, true)SimNomes 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).

E o 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 beginTransaction volta ao que era (roll back = "rolar de volta"). Fica no catch: 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 juntosVenda (estoque + registro), transferência (sai de uma conta + entra na outra)
Você grava muitas linhas de uma vezImportar 1.000 produtos de um CSV: tudo ou nada (e, no SQLite, muito mais rápido)
Não precisaUm único INSERT, UPDATE ou DELETE sozinho: ele já é "tudo ou nada" por si

Quando dá errado

Erro de sintaxe com apóstrofo (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.

A busca não acha nada, e não dá erro (faltou um valor)
<?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.

As mudanças "sumiram" (esqueceu o 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étodoO que fazExemplo
$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 trueexecute([$id]), execute(["id" => $id])
$stmt->bindValue($lacuna, $valor, $tipo)Liga um valor a uma lacuna, com tipobindValue(":qtd", 5, PDO::PARAM_INT)
$stmt->bindParam($lacuna, $variavel)Liga uma variável, lida no executebindParam(1, $nome)
$stmt->fetch() / fetchAll() / fetchColumn()Lê o resultadoveja Conectar com PDO
$stmt->rowCount()Linhas afetadas pelo último executeveja CRUD com PDO
$stmt->debugDumpParams()Mostra o SQL preparado e os valores ligadospara depurar
$pdo->quote($texto)Põe aspas e escapa para este banco'copo d''água'
$pdo->beginTransaction()Abre uma transaçãoantes do try
$pdo->commit()Confirma tudo desde o beginTransactionno fim do try
$pdo->rollBack()Desfaz tudo desde o beginTransactionno catch
$pdo->inTransaction()Há uma transação aberta?true/false
PDO::PARAM_STR, PARAM_INT, PARAM_BOOL, PARAM_NULLTipos para bindValue

Exercícios

1. A lacuna

Complete o SQL para buscar o produto pelo id com um marcador posicional:

$stmt = $pdo->prepare("SELECT * FROM produtos WHERE id = ");

2. Os valores

Complete para executar o comando "… WHERE nome = :nome" com o valor da variável $nome:

$stmt->execute();

3. Desfazer

Complete o catch para desfazer a transação:

} catch (Exception $e) { $pdo-> }

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'='1 mostrou até o produto escondido).
  • O primeiro sintoma costuma ser um apóstrofo (copo d'água) dando syntax 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. No LIKE, o % vai no valor.
  • bindValue(…, PDO::PARAM_INT) quando o tipo importa, como no LIMIT.
  • Marcador é só para valores. Nomes de coluna e tabela: lista de permitidas.
  • Nunca addslashes nem "tirar aspas na mão". O htmlspecialchars é para a saída, não para o banco.
  • Transação: beginTransaction() → comandos → commit(); no catch, rollBack(). Tudo ou nada.

Próxima aula: MySQL e MariaDB, para levar tudo isso para um servidor de banco de dados.