Banco de dados com PDO: consultas seguras

1. Introdução ao PDO e conexão segura

PDO (PHP Data Objects) é uma extensão do PHP que fornece uma camada de abstração para acesso a bancos de dados. Sua principal vantagem é permitir que você trabalhe com diferentes sistemas de banco de dados (MySQL, PostgreSQL, SQLite, etc.) usando a mesma interface, além de oferecer recursos essenciais de segurança como prepared statements.

Para estabelecer uma conexão segura, utilizamos uma string DSN (Data Source Name) que define o driver, host, nome do banco e charset:

<?php
$host = 'localhost';
$dbname = 'meu_banco';
$username = 'usuario';
$password = 'senha_segura';

try {
    $dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
    $pdo = new PDO($dsn, $username, $password, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
    echo "Conexão estabelecida com sucesso!";
} catch (PDOException $e) {
    echo "Erro na conexão: " . $e->getMessage();
    exit;
}

A configuração PDO::ATTR_EMULATE_PREPARES => false desativa a emulação de prepared statements, garantindo que o banco de dados processe os parâmetros de forma nativa e segura.

2. Prepared statements: o pilar da segurança

Prepared statements separam a estrutura da query dos dados fornecidos pelo usuário. Isso impede que dados maliciosos sejam interpretados como parte da query, eliminando o risco de SQL injection.

Existem dois tipos de placeholders:

Placeholders nomeados (:nome):

$sql = "SELECT * FROM usuarios WHERE email = :email AND status = :status";
$stmt = $pdo->prepare($sql);
$stmt->bindParam(':email', $email);
$stmt->bindParam(':status', $status);

Placeholders posicionais (?):

$sql = "SELECT * FROM usuarios WHERE email = ? AND status = ?";
$stmt = $pdo->prepare($sql);
$stmt->bindParam(1, $email);
$stmt->bindParam(2, $status);

Diferença entre bindParam() e bindValue():
- bindParam() vincula uma referência à variável — se a variável mudar antes de execute(), o novo valor será usado
- bindValue() vincula o valor atual da variável no momento da chamada

$nome = "João";
$stmt->bindParam(':nome', $nome); // Referência
$nome = "Maria";
$stmt->execute(); // Insere "Maria"

$nome = "João";
$stmt->bindValue(':nome', $nome); // Valor atual
$nome = "Maria";
$stmt->execute(); // Insere "João"

3. Executando consultas SELECT com segurança

Para consultas SELECT com parâmetros dinâmicos, sempre use prepare() e execute():

$busca = $_GET['busca'] ?? '';

$sql = "SELECT id, nome, email FROM usuarios WHERE nome LIKE :termo";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':termo', "%$busca%");
$stmt->execute();

// fetch() para um único resultado
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);

// fetchAll() para múltiplos resultados
$usuarios = $stmt->fetchAll(PDO::FETCH_OBJ);

foreach ($usuarios as $user) {
    echo $user->nome . " - " . $user->email . "<br>";
}

Modos de fetch comuns:
- PDO::FETCH_ASSOC: retorna array associativo
- PDO::FETCH_OBJ: retorna objeto anônimo
- PDO::FETCH_NUM: retorna array numérico
- PDO::FETCH_BOTH: retorna array com ambos índices

Prevenção de SQL injection em cláusulas WHERE e LIKE:

// ❌ INSEGURO - concatenação direta
$stmt = $pdo->query("SELECT * FROM usuarios WHERE id = $id");

// ✅ SEGURO - prepared statement
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE id = :id");
$stmt->execute([':id' => $id]);

4. Inserção, atualização e exclusão seguras

INSERT com prepared statements e retorno do último ID:

$dados = [
    'nome' => 'Ana Silva',
    'email' => 'ana@exemplo.com',
    'senha' => password_hash('minha_senha', PASSWORD_DEFAULT)
];

$sql = "INSERT INTO usuarios (nome, email, senha) VALUES (:nome, :email, :senha)";
$stmt = $pdo->prepare($sql);
$stmt->execute($dados);

$novoId = $pdo->lastInsertId();
echo "Usuário cadastrado com ID: $novoId";

UPDATE com parâmetros dinâmicos:

$sql = "UPDATE usuarios SET nome = :nome, email = :email WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':nome' => 'Maria Souza',
    ':email' => 'maria@exemplo.com',
    ':id' => 5
]);

$linhasAfetadas = $stmt->rowCount();
echo "$linhasAfetadas registro(s) atualizado(s)";

DELETE com parâmetros:

$sql = "DELETE FROM usuarios WHERE id = :id AND ativo = 0";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => 10]);

if ($stmt->rowCount() > 0) {
    echo "Usuário excluído com sucesso";
} else {
    echo "Nenhum registro encontrado para exclusão";
}

5. Transações com PDO: atomicidade e consistência

Transações garantem que um conjunto de operações seja executado completamente ou nenhuma delas seja aplicada. Exemplo de transferência bancária:

try {
    $pdo->beginTransaction();

    // Remover valor da conta de origem
    $sql = "UPDATE contas SET saldo = saldo - :valor WHERE id = :origem AND saldo >= :valor";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([':valor' => 500, ':origem' => 1]);

    if ($stmt->rowCount() === 0) {
        throw new Exception("Saldo insuficiente");
    }

    // Adicionar valor na conta de destino
    $sql = "UPDATE contas SET saldo = saldo + :valor WHERE id = :destino";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([':valor' => 500, ':destino' => 2]);

    // Registrar transação
    $sql = "INSERT INTO transacoes (conta_origem, conta_destino, valor, data)
            VALUES (:origem, :destino, :valor, NOW())";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([':origem' => 1, ':destino' => 2, ':valor' => 500]);

    $pdo->commit();
    echo "Transferência realizada com sucesso!";
} catch (Exception $e) {
    $pdo->rollBack();
    echo "Erro na transferência: " . $e->getMessage();
}

6. Tratamento de erros e boas práticas

Modos de erro no PDO:

// ERRMODE_SILENT - não exibe erros (padrão)
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);

// ERRMODE_WARNING - exibe warnings
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);

// ERRMODE_EXCEPTION - lança exceções (recomendado)
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

Boas práticas para logging e mensagens amigáveis:

try {
    // Operações com banco
} catch (PDOException $e) {
    // Log detalhado para desenvolvedores
    error_log("Erro PDO: " . $e->getMessage() . " em " . $e->getFile() . ":" . $e->getLine());

    // Mensagem amigável para usuário
    echo "Ocorreu um erro ao processar sua solicitação. Tente novamente mais tarde.";
}

Fechamento de conexão e gerenciamento de recursos:

// O PDO fecha automaticamente ao final do script
// Mas você pode forçar o fechamento:
$pdo = null;

// Liberar resultados antes de nova query
$stmt->closeCursor();

7. Trabalhando com tipos de dados e conversão

Bind de tipos específicos:

$sql = "INSERT INTO produtos (nome, preco, quantidade, ativo, descricao)
        VALUES (:nome, :preco, :quantidade, :ativo, :descricao)";

$stmt = $pdo->prepare($sql);
$stmt->bindValue(':nome', 'Teclado Mecânico', PDO::PARAM_STR);
$stmt->bindValue(':preco', 299.90, PDO::PARAM_STR);
$stmt->bindValue(':quantidade', 50, PDO::PARAM_INT);
$stmt->bindValue(':ativo', true, PDO::PARAM_BOOL);
$stmt->bindValue(':descricao', null, PDO::PARAM_NULL);
$stmt->execute();

Cuidados com datas e booleanos:

// Datas: sempre usar formato ISO (Y-m-d)
$data = '2024-01-15';
$stmt->bindValue(':data', $data, PDO::PARAM_STR);

// Booleanos: MySQL converte true/false para 1/0
$ativo = true;
$stmt->bindValue(':ativo', $ativo, PDO::PARAM_BOOL);

// Arrays: não são suportados diretamente, precisam de tratamento especial
$ids = [1, 2, 3];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT * FROM usuarios WHERE id IN ($placeholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($ids);

8. Exemplo completo: CRUD seguro com PDO

<?php
class UsuarioDAO {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function create(string $nome, string $email, string $senha): int {
        $sql = "INSERT INTO usuarios (nome, email, senha) VALUES (:nome, :email, :senha)";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([
            ':nome' => $nome,
            ':email' => $email,
            ':senha' => password_hash($senha, PASSWORD_DEFAULT)
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function read(int $id): ?array {
        $sql = "SELECT id, nome, email, criado_em FROM usuarios WHERE id = :id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([':id' => $id]);
        $resultado = $stmt->fetch(PDO::FETCH_ASSOC);
        return $resultado ?: null;
    }

    public function update(int $id, string $nome, string $email): bool {
        $sql = "UPDATE usuarios SET nome = :nome, email = :email WHERE id = :id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([
            ':nome' => $nome,
            ':email' => $email,
            ':id' => $id
        ]);
        return $stmt->rowCount() > 0;
    }

    public function delete(int $id): bool {
        $sql = "DELETE FROM usuarios WHERE id = :id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }
}

// Teste de segurança: tentativa de SQL injection
$pdo = new PDO('mysql:host=localhost;dbname=teste;charset=utf8mb4', 'root', '');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

$dao = new UsuarioDAO($pdo);

// Tentativa de SQL injection - será neutralizada pelo prepared statement
$tentativaInjection = "1; DROP TABLE usuarios; --";
$usuario = $dao->read($tentativaInjection);
// Resultado: retorna null (não encontra ID) ou erro de tipo (se ID não for numérico)
// A tabela NÃO será dropada

O PDO com prepared statements transforma qualquer tentativa de SQL injection em um parâmetro inofensivo, garantindo que os dados fornecidos pelo usuário nunca sejam interpretados como parte da estrutura SQL.

Referências