PDO Avançado, Prepared Statements e Padrão Repository

PDO Avançado, Prepared Statements e Padrão Repository

Configurar os atributos da conexão, separar sempre os parâmetros da query, escolher o modo de fetch que evita código de mapeamento e esconder o SQL atrás de uma interface de repositório. O PDO como aparece em sistema no ar, com as armadilhas do sql_mode e do rollBack dentro do catch.
PHP

• • 22 min de leitura

PDO (PHP Data Objects) é a camada de abstração de banco de dados nativa do PHP. Ela permite escrever código que funciona com MySQL, PostgreSQL, SQLite e outros SGBDs com mudanças mínimas. Mas a maioria dos desenvolvedores usa apenas a superfície do PDO — conectar, executar uma query, buscar resultados. O uso profissional vai muito além: configuração correta de atributos de conexão, prepared statements reutilizáveis, transações com savepoints, modos de fetch tipados e o padrão Repository para isolar a lógica de acesso a dados do restante da aplicação.

Este artigo cobre o PDO do jeito que é usado em sistemas de produção: sem vulnerabilidades de SQL injection, com tratamento correto de erros, performance otimizada e uma camada de abstração que permite trocar o banco de dados sem reescrever a lógica de negócio.

Configuração correta da conexão PDO

A maioria dos tutoriais mostra o mínimo para conectar. Em produção, há uma série de atributos que precisam ser configurados explicitamente para garantir segurança, performance e comportamento previsível.

<?php
declare(strict_types=1);

function criarConexao(
    string $host,
    string $banco,
    string $usuario,
    string $senha,
    int    $porta = 3306,
): \PDO {
    $dsn = "mysql:host={$host};port={$porta};dbname={$banco};charset=utf8mb4";

    $opcoes = [
        // Lança PDOException em vez de retornar false — essencial
        \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_EXCEPTION,

        // Retorna arrays associativos por padrão
        \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,

        // Desativa emulação de prepared statements — usa prepared real do MySQL
        // Mais seguro e permite tipos corretos (int retorna como int, não string)
        \PDO::ATTR_EMULATE_PREPARES   => false,

        // Timeout de conexão em segundos
        \PDO::ATTR_TIMEOUT            => 5,

        // Desativa autocommit — transações explícitas
        // \PDO::ATTR_AUTOCOMMIT      => false, // ative se usar transações por padrão

        // MySQL específico: modo estrito — rejeita dados truncados/inválidos.
        // Atenção: atribuir sql_mode direto APAGA os modos que já vinham
        // por padrão (ONLY_FULL_GROUP_BY, NO_ZERO_DATE, NO_ENGINE_SUBSTITUTION).
        // Concatene em vez de substituir.
        \PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci,
                                          sql_mode = CONCAT(@@sql_mode, ',STRICT_ALL_TABLES')",
    ];

    try {
        $pdo = new \PDO($dsn, $usuario, $senha, $opcoes);
        return $pdo;
    } catch (\PDOException $e) {
        // NÃO exponha detalhes de conexão em produção
        // Logue $e->getMessage() internamente, relance genérico
        throw new \RuntimeException(
            "Falha ao conectar ao banco de dados.",
            (int) $e->getCode(),
            $e
        );
    }
}

// Uso com variáveis de ambiente — nunca hardcode credenciais
$pdo = criarConexao(
    host:    getenv('DB_HOST')     ?: 'localhost',
    banco:   getenv('DB_DATABASE') ?: 'meuapp',
    usuario: getenv('DB_USERNAME') ?: 'root',
    senha:   getenv('DB_PASSWORD') ?: '',
);

Prepared statements — segurança e performance

Prepared statements são a defesa primária contra SQL injection. O banco compila a query uma vez e a executa com parâmetros separados — os parâmetros nunca são interpolados na query, então valores maliciosos não alteram a estrutura SQL. Um segundo benefício: quando a mesma query é executada múltiplas vezes, o banco reutiliza o plano de execução compilado.

<?php
declare(strict_types=1);

// ── PARÂMETROS POSICIONAIS (?) ────────────────────────────────────────
// Simples, mas a ordem importa
$stmt = $pdo->prepare("
    SELECT id, nome, email, criado_em
    FROM usuarios
    WHERE ativo = ? AND perfil = ?
    ORDER BY nome
    LIMIT ?
");

// execute() recebe array de valores na ordem dos ?
$stmt->execute([true, 'admin', 50]);
$usuarios = $stmt->fetchAll(); // array de arrays associativos

// ── PARÂMETROS NOMEADOS (:nome) ────────────────────────────────────────
// Mais legível, a ordem não importa, pode repetir o mesmo parâmetro
$stmt = $pdo->prepare("
    SELECT *
    FROM produtos
    WHERE categoria = :categoria
      AND preco BETWEEN :min AND :max
      AND estoque > :estoque_minimo
    ORDER BY preco
");

$stmt->execute([
    'categoria'       => 'Eletrônicos',
    'min'             => 100.00,
    'max'             => 5000.00,
    'estoque_minimo'  => 0,
]);
$produtos = $stmt->fetchAll();

// ── REUTILIZANDO O MESMO PREPARED STATEMENT ───────────────────────────
// Prepara uma vez, executa muitas vezes — performance real
$stmtInserir = $pdo->prepare("
    INSERT INTO logs (usuario_id, acao, ip, criado_em)
    VALUES (:usuario_id, :acao, :ip, NOW())
");

$eventos = [
    ['usuario_id' => 1, 'acao' => 'login',   'ip' => '192.168.1.1'],
    ['usuario_id' => 1, 'acao' => 'busca',   'ip' => '192.168.1.1'],
    ['usuario_id' => 2, 'acao' => 'logout',  'ip' => '10.0.0.5'],
];

foreach ($eventos as $evento) {
    $stmtInserir->execute($evento);
    // lastInsertId() após cada execute() se precisar do ID gerado
}

// ── BINDPARAM vs BINDVALUE ─────────────────────────────────────────────
// bindParam() — vincula por referência (valor é lido na execução)
// bindValue() — vincula por valor (valor é capturado agora)
$stmtBuscar = $pdo->prepare("SELECT * FROM pedidos WHERE id = :id");

// bindValue com tipo explícito — garante que o banco recebe um inteiro
$stmtBuscar->bindValue(':id', 42, \PDO::PARAM_INT);
$stmtBuscar->execute();
$pedido = $stmtBuscar->fetch();

// bindParam — útil em loops onde a variável muda
$id = 0;
$stmtBuscar->bindParam(':id', $id, \PDO::PARAM_INT);
foreach ([1, 2, 3, 4, 5] as $id) {
    $stmtBuscar->execute(); // usa o valor atual de $id
    $resultado = $stmtBuscar->fetch();
}

Modos de fetch — controlando o formato dos resultados

O PDO oferece múltiplos modos de fetch que determinam como os dados são retornados. Escolher o modo correto reduz código desnecessário de mapeamento.

<?php
declare(strict_types=1);

$stmt = $pdo->prepare("SELECT id, nome, email, preco FROM produtos WHERE ativo = 1");
$stmt->execute();

// FETCH_ASSOC — array associativo (padrão configurado)
$linha = $stmt->fetch(\PDO::FETCH_ASSOC);
// ['id' => 1, 'nome' => 'Teclado', 'email' => null, 'preco' => '350.00']

// FETCH_OBJ — objeto stdClass com propriedades
$linha = $stmt->fetch(\PDO::FETCH_OBJ);
// $linha->id, $linha->nome, $linha->preco

// FETCH_CLASS — mapeia diretamente para uma classe
// As propriedades são preenchidas ANTES de __construct() ser chamado
class Produto
{
    public int    $id    = 0;
    public string $nome  = '';
    public float  $preco = 0.0;
}

$stmt->setFetchMode(\PDO::FETCH_CLASS, Produto::class);
/** @var Produto $produto */
$produto = $stmt->fetch();
echo $produto->nome  . "\n"; // Teclado
echo $produto->preco . "\n"; // 350

// fetchAll com FETCH_CLASS
$produtos = $stmt->fetchAll(\PDO::FETCH_CLASS, Produto::class);
// array de objetos Produto

// FETCH_COLUMN — retorna apenas a primeira coluna como array simples
$stmt = $pdo->prepare("SELECT nome FROM produtos ORDER BY nome");
$stmt->execute();
$nomes = $stmt->fetchAll(\PDO::FETCH_COLUMN);
// ['Mouse', 'Teclado', 'Monitor']

// FETCH_KEY_PAIR — array [primeira_coluna => segunda_coluna]
$stmt = $pdo->prepare("SELECT id, nome FROM produtos");
$stmt->execute();
$mapa = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR);
// [1 => 'Teclado', 2 => 'Mouse', 3 => 'Monitor']

// FETCH_UNIQUE — primeira coluna como chave, resto como array/objeto
$stmt = $pdo->prepare("SELECT id, nome, preco FROM produtos");
$stmt->execute();
$porId = $stmt->fetchAll(\PDO::FETCH_UNIQUE | \PDO::FETCH_ASSOC);
// [1 => ['nome' => 'Teclado', 'preco' => '350.00'], ...]

Transações com savepoints

Transações garantem que um conjunto de operações seja atômico — ou todas têm sucesso, ou nenhuma. Savepoints permitem desfazer apenas parte de uma transação, criando pontos de restauração intermediários.

<?php
declare(strict_types=1);

function transferirSaldo(\PDO $pdo, int $deId, int $paraId, float $valor): void
{
    if ($valor <= 0) {
        throw new \InvalidArgumentException("Valor de transferência deve ser positivo.");
    }

    $pdo->beginTransaction();
    try {
        // Trava as duas linhas de uma vez e sempre na mesma ordem de id.
        // Com FOR UPDATE só na origem, duas transferências simúltaneas em
        // sentidos opostos (A para B e B para A) travam uma linha cada e entram
        // em deadlock ao tentar atualizar a outra.
        $stmt = $pdo->prepare(
            "SELECT id, saldo FROM contas WHERE id IN (?, ?) ORDER BY id FOR UPDATE"
        );

        $stmt->execute([$deId, $paraId]);
        $saldos = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR);

        if (!isset($saldos[$deId], $saldos[$paraId])) {
            throw new \RuntimeException("Conta de origem ou de destino inexistente.");
        }

        $saldoOrigem = (float) $saldos[$deId];

        if ($saldoOrigem < $valor) {
            throw new \RuntimeException(
                "Saldo insuficiente: disponível R$ {$saldoOrigem}, solicitado R$ {$valor}"
            );
        }

        // Débito
        $pdo->prepare("UPDATE contas SET saldo = saldo - ? WHERE id = ?")
            ->execute([$valor, $deId]);

        // Crédito
        $pdo->prepare("UPDATE contas SET saldo = saldo + ? WHERE id = ?")
            ->execute([$valor, $paraId]);

        // Registra a transferência
        $pdo->prepare(
            "INSERT INTO transferencias (de_id, para_id, valor, criado_em)
             VALUES (?, ?, ?, NOW())"
        )->execute([$deId, $paraId, $valor]);

        $pdo->commit();
    } catch (\Throwable $e) {
        // Sem o inTransaction(), um erro que já tenha encerrado a transação
        // faz o rollBack() lançar PDOException e a exceção original se perde.
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e; // relança para o chamador decidir o que fazer
    }
}

// Savepoints — controle granular dentro de uma transação
function processarPedidoComSavepoint(\PDO $pdo, array $pedido): void
{
    $pdo->beginTransaction();
    try {
        // Insere o pedido
        $pdo->prepare("INSERT INTO pedidos (cliente_id, total) VALUES (?, ?)")
            ->execute([$pedido['cliente_id'], $pedido['total']]);
        $pedidoId = (int) $pdo->lastInsertId();

        // Savepoint antes dos itens — se falhar, desfaz só os itens
        $pdo->exec("SAVEPOINT sp_itens");
        try {
            foreach ($pedido['itens'] as $item) {
                $pdo->prepare(
                    "INSERT INTO pedido_itens (pedido_id, produto_id, quantidade, preco)
                     VALUES (?, ?, ?, ?)"
                )->execute([$pedidoId, $item['produto_id'], $item['quantidade'], $item['preco']]);

                // Atualiza estoque
                $affected = $pdo->prepare(
                    "UPDATE produtos SET estoque = estoque - ?
                     WHERE id = ? AND estoque >= ?"
                );
                $affected->execute([$item['quantidade'], $item['produto_id'], $item['quantidade']]);

                if ($affected->rowCount() === 0) {
                    throw new \RuntimeException(
                        "Estoque insuficiente para produto #{$item['produto_id']}"
                    );
                }
            }
            $pdo->exec("RELEASE SAVEPOINT sp_itens");
        } catch (\RuntimeException $e) {
            // Desfaz só os itens, mantém o pedido
            $pdo->exec("ROLLBACK TO SAVEPOINT sp_itens");
            // Marca o pedido como falho em vez de cancelar tudo
            $pdo->prepare("UPDATE pedidos SET status = 'falhou' WHERE id = ?")
                ->execute([$pedidoId]);
        }

        $pdo->commit();
    } catch (\Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}

O padrão Repository

O padrão Repository isola a lógica de acesso a dados do restante da aplicação. Serviços de domínio não sabem se os dados vêm de MySQL, PostgreSQL, uma API REST ou um arquivo JSON — eles apenas chamam métodos do repositório e recebem objetos de domínio.

<?php
declare(strict_types=1);

// Entidade de domínio — sem dependência de PDO ou SQL
final class Produto
{
    public function __construct(
        public readonly int    $id,
        public readonly string $nome,
        public readonly float  $preco,
        public readonly int    $estoque,
        public readonly bool   $ativo,
    ) {}

    public function comEstoque(int $novo): static
    {
        return new static($this->id, $this->nome, $this->preco, $novo, $this->ativo);
    }
}

// Interface do repositório — contrato de domínio, sem SQL
interface ProdutoRepositorioInterface
{
    public function buscarPorId(int $id): ?Produto;
    /** @return Produto[] */
    public function buscarAtivos(): array;
    /** @return Produto[] */
    public function buscarPorCategoria(string $categoria, int $limite = 20): array;
    public function salvar(Produto $produto): Produto;
    public function deletar(int $id): bool;
}

// Implementação PDO — traduz dados relacionais para objetos de domínio
class ProdutoRepositorioPDO implements ProdutoRepositorioInterface
{
    public function __construct(private readonly \PDO $pdo) {}

    public function buscarPorId(int $id): ?Produto
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, nome, preco, estoque, ativo FROM produtos WHERE id = ?"
        );
        $stmt->execute([$id]);
        $linha = $stmt->fetch();
        return $linha ? $this->hidratar($linha) : null;
    }

    public function buscarAtivos(): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, nome, preco, estoque, ativo FROM produtos WHERE ativo = 1 ORDER BY nome"
        );
        $stmt->execute();
        return array_map($this->hidratar(...), $stmt->fetchAll());
    }

    public function buscarPorCategoria(string $categoria, int $limite = 20): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT p.id, p.nome, p.preco, p.estoque, p.ativo
             FROM produtos p
             JOIN categorias c ON c.id = p.categoria_id
             WHERE c.slug = :categoria AND p.ativo = 1
             ORDER BY p.nome
             LIMIT :limite"
        );
        $stmt->bindValue(':categoria', $categoria);
        $stmt->bindValue(':limite',    $limite, \PDO::PARAM_INT);
        $stmt->execute();
        return array_map($this->hidratar(...), $stmt->fetchAll());
    }

    public function salvar(Produto $produto): Produto
    {
        if ($produto->id === 0) {
            // INSERT
            $stmt = $this->pdo->prepare(
                "INSERT INTO produtos (nome, preco, estoque, ativo)
                 VALUES (:nome, :preco, :estoque, :ativo)"
            );
            $stmt->execute([
                'nome'    => $produto->nome,
                'preco'   => $produto->preco,
                'estoque' => $produto->estoque,
                'ativo'   => $produto->ativo,
            ]);
            $novoId = (int) $this->pdo->lastInsertId();
            return new Produto($novoId, $produto->nome, $produto->preco, $produto->estoque, $produto->ativo);
        }

        // UPDATE
        $this->pdo->prepare(
            "UPDATE produtos
             SET nome = :nome, preco = :preco, estoque = :estoque, ativo = :ativo
             WHERE id = :id"
        )->execute([
            'nome'    => $produto->nome,
            'preco'   => $produto->preco,
            'estoque' => $produto->estoque,
            'ativo'   => $produto->ativo,
            'id'      => $produto->id,
        ]);
        return $produto;
    }

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

    // Hidratação — transforma array do banco em objeto de domínio
    private function hidratar(array $linha): Produto
    {
        return new Produto(
            id:      (int)  $linha['id'],
            nome:           $linha['nome'],
            preco:   (float) $linha['preco'],
            estoque: (int)  $linha['estoque'],
            ativo:   (bool) $linha['ativo'],
        );
    }
}

// Serviço de domínio — não sabe nada sobre PDO ou SQL
class EstoqueService
{
    public function __construct(
        private readonly ProdutoRepositorioInterface $repositorio,
    ) {}

    public function reduzirEstoque(int $produtoId, int $quantidade): Produto
    {
        $produto = $this->repositorio->buscarPorId($produtoId);
        if ($produto === null) {
            throw new \RuntimeException("Produto #{$produtoId} não encontrado.");
        }
        if ($produto->estoque < $quantidade) {
            throw new \RuntimeException(
                "Estoque insuficiente: disponível {$produto->estoque}, solicitado {$quantidade}."
            );
        }
        $atualizado = $produto->comEstoque($produto->estoque - $quantidade);
        return $this->repositorio->salvar($atualizado);
    }
}

O que separa o PDO de tutorial do PDO de produção não é nenhuma função exótica: é a configuração explícita dos atributos de conexão, o hábito de nunca interpolar valor dentro da query e a disciplina de manter o SQL atrás de uma interface. Um repositório que devolve objetos de domínio, e não arrays soltos, muda o resto do sistema por efeito dominó: o serviço deixa de conhecer o nome das colunas, e trocar o banco vira detalhe de infraestrutura.

Duas linhas merecem desconfiança toda vez que aparecerem no seu código. O sql_mode atribuído direto no INIT_COMMAND apaga os modos que o servidor já trazia — concatene com @@sql_mode. E o rollBack() solto dentro do catch troca a exceção que interessa por um "there is no active transaction" quando a transação já havia caído.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie uma classe Conexao com método estático obter(): PDO que retorna uma instância singleton configurada com todos os atributos de produção discutidos no artigo. Use variáveis de ambiente para as credenciais.

Ver resposta

✓ Resposta: Credencial nunca fica no código: vem de getenv(). O throw como expressão (PHP 8) faz a aplicação morrer no boot se faltar variável — melhor que conectar em banco errado.

<?php

declare(strict_types=1);

final class Conexao
{
    private static ?PDO $instancia = null;

    // Singleton de verdade: ninguém instancia nem clona de fora.
    private function __construct() {}
    private function __clone() {}

    public static function obter(): PDO
    {
        if (self::$instancia instanceof PDO) {
            return self::$instancia;
        }

        $dsn = sprintf(
            'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
            getenv('DB_HOST') ?: '127.0.0.1',
            getenv('DB_PORT') ?: '3306',
            getenv('DB_NAME') ?: throw new RuntimeException('DB_NAME não definida'),
        );

        self::$instancia = new PDO(
            $dsn,
            getenv('DB_USER') ?: throw new RuntimeException('DB_USER não definida'),
            getenv('DB_PASS') ?: '',
            [
                // Erro vira exceção, em vez de retorno falso silencioso.
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                // O ponto mais importante da lista: sem emulação, quem monta a
                // query com os parâmetros é o servidor, não o driver.
                PDO::ATTR_EMULATE_PREPARES   => false,
                // INT no banco chega como int no PHP, não como string.
                PDO::ATTR_STRINGIFY_FETCHES  => false,
            ],
        );

        return self::$instancia;
    }
}

// Uso:
$pdo = Conexao::obter();

Exercício 2

Implemente um UsuarioRepositorio com os métodos buscarPorEmail(string $email): ?Usuario, buscarPorId(int $id): ?Usuario, criar(string $nome, string $email, string $senhaHash): Usuario e atualizarUltimoLogin(int $id): void. Use prepared statements nomeados em todos os métodos.

Ver resposta

✓ Resposta: O repositório devolve Usuario, não array: quem chama não precisa saber nome de coluna. E a senha nunca é gerada aqui — o hash chega pronto, feito com password_hash().

<?php

declare(strict_types=1);

final class Usuario
{
    public function __construct(
        public readonly int $id,
        public readonly string $nome,
        public readonly string $email,
        public readonly string $senhaHash,
        public readonly ?DateTimeImmutable $ultimoLogin = null,
    ) {}

    public static function daLinha(array $linha): self
    {
        return new self(
            (int) $linha['id'],
            $linha['nome'],
            $linha['email'],
            $linha['senha_hash'],
            $linha['ultimo_login'] !== null
                ? new DateTimeImmutable($linha['ultimo_login'])
                : null,
        );
    }
}

final class UsuarioRepositorio
{
    public function __construct(private readonly PDO $pdo) {}

    private const CAMPOS = 'id, nome, email, senha_hash, ultimo_login';

    public function buscarPorEmail(string $email): ?Usuario
    {
        $stmt = $this->pdo->prepare(
            'SELECT ' . self::CAMPOS . ' FROM usuarios WHERE email = :email LIMIT 1'
        );
        $stmt->execute([':email' => $email]);
        $linha = $stmt->fetch();

        return $linha ? Usuario::daLinha($linha) : null;
    }

    public function buscarPorId(int $id): ?Usuario
    {
        $stmt = $this->pdo->prepare(
            'SELECT ' . self::CAMPOS . ' FROM usuarios WHERE id = :id LIMIT 1'
        );
        $stmt->execute([':id' => $id]);
        $linha = $stmt->fetch();

        return $linha ? Usuario::daLinha($linha) : null;
    }

    public function criar(string $nome, string $email, string $senhaHash): Usuario
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO usuarios (nome, email, senha_hash, criado_em)
             VALUES (:nome, :email, :senha_hash, NOW())'
        );

        try {
            $stmt->execute([
                ':nome'       => $nome,
                ':email'      => $email,
                ':senha_hash' => $senhaHash,
            ]);
        } catch (PDOException $e) {
            // 23000 = violação de constraint. Aqui, o UNIQUE do e-mail.
            if ($e->getCode() === '23000') {
                throw new RuntimeException("E-mail já cadastrado: {$email}", 0, $e);
            }
            throw $e;
        }

        return new Usuario((int) $this->pdo->lastInsertId(), $nome, $email, $senhaHash);
    }

    public function atualizarUltimoLogin(int $id): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE usuarios SET ultimo_login = NOW() WHERE id = :id'
        );
        $stmt->execute([':id' => $id]);
    }
}

Exercício 3

Implemente a função transferirPontos(\PDO $pdo, int $deId, int $paraId, int $pontos): void que usa uma transação para debitar pontos de um usuário e creditar em outro, garantindo que o saldo nunca fique negativo.

Ver resposta

✓ Resposta: Detalhe que derruba muita gente: no MySQL, rowCount() conta linhas alteradas, não encontradas. Crédito de valor que não muda nada retornaria 0. Como aqui :pontos é sempre > 0, o teste é seguro — mas em UPDATE que pode gravar o mesmo valor, prefira conferir com um SELECT dentro da transação.

<?php

declare(strict_types=1);

function transferirPontos(\PDO $pdo, int $deId, int $paraId, int $pontos): void
{
    if ($pontos <= 0) {
        throw new InvalidArgumentException('A quantidade de pontos tem de ser positiva.');
    }

    if ($deId === $paraId) {
        throw new InvalidArgumentException('Origem e destino são o mesmo usuário.');
    }

    $pdo->beginTransaction();

    try {
        // A trava real está no WHERE, não numa leitura anterior: o próprio
        // banco recusa o débito que deixaria o saldo negativo. Entre um
        // SELECT de saldo e um UPDATE cabe outra transação — aqui, não cabe.
        $debito = $pdo->prepare(
            'UPDATE carteiras
                SET saldo = saldo - :pontos
              WHERE usuario_id = :id
                AND saldo >= :pontos'
        );
        $debito->execute([':pontos' => $pontos, ':id' => $deId]);

        if ($debito->rowCount() === 0) {
            throw new RuntimeException(
                "Saldo insuficiente ou carteira inexistente (usuário {$deId})."
            );
        }

        $credito = $pdo->prepare(
            'UPDATE carteiras SET saldo = saldo + :pontos WHERE usuario_id = :id'
        );
        $credito->execute([':pontos' => $pontos, ':id' => $paraId]);

        if ($credito->rowCount() === 0) {
            throw new RuntimeException("Carteira de destino inexistente ({$paraId}).");
        }

        $pdo->commit();
    } catch (Throwable $e) {
        // Sem rollback, a conexão fica com transação aberta e contamina
        // a próxima operação que a reaproveitar.
        $pdo->rollBack();
        throw $e;
    }
}

Exercício 4

Crie um RelatorioRepositorio com método produtosMaisVendidos(int $limite, \DateTimeImmutable $de, \DateTimeImmutable $ate): array que retorna produtos com a soma de quantidades vendidas em um período. Use FETCH_CLASS para hidratar os resultados.

Ver resposta

✓ Resposta: FETCH_CLASS puro atribui as propriedades antes de chamar o construtor. Se a classe tiver construtor com argumentos obrigatórios, use FETCH_CLASS | PDO::FETCH_PROPS_LATE — senão o construtor sobrescreve o que veio do banco.

<?php

declare(strict_types=1);

final class ProdutoVendido
{
    // O FETCH_CLASS escreve nestas propriedades pelo nome da coluna,
    // então os nomes têm de bater com os alias do SELECT.
    public int $id = 0;
    public string $nome = '';
    public int $quantidade = 0;
    public string $receita = '0.00';

    public function receitaFormatada(): string
    {
        return 'R$ ' . number_format((float) $this->receita, 2, ',', '.');
    }
}

final class RelatorioRepositorio
{
    public function __construct(private readonly PDO $pdo) {}

    /** @return ProdutoVendido[] */
    public function produtosMaisVendidos(
        int $limite,
        \DateTimeImmutable $de,
        \DateTimeImmutable $ate,
    ): array {
        $stmt = $this->pdo->prepare(
            'SELECT  pr.id                                    AS id,
                     pr.nome                                  AS nome,
                     SUM(ip.quantidade)                       AS quantidade,
                     SUM(ip.quantidade * ip.preco_unitario)   AS receita
               FROM  itens_pedido ip
               JOIN  pedidos  pe ON pe.id = ip.pedido_id
               JOIN  produtos pr ON pr.id = ip.produto_id
              WHERE  pe.criado_em BETWEEN :de AND :ate
                AND  pe.status = :status
           GROUP BY  pr.id, pr.nome
           ORDER BY  quantidade DESC
              LIMIT  :limite'
        );

        $stmt->bindValue(':de',     $de->format('Y-m-d H:i:s'));
        $stmt->bindValue(':ate',    $ate->format('Y-m-d H:i:s'));
        $stmt->bindValue(':status', 'pago');
        // LIMIT tem de ser inteiro. Com EMULATE_PREPARES = false o valor vai
        // ao servidor como string se você não disser PARAM_INT — e o MySQL
        // recusa `LIMIT '10'` com erro de sintaxe.
        $stmt->bindValue(':limite', $limite, PDO::PARAM_INT);

        $stmt->execute();

        return $stmt->fetchAll(PDO::FETCH_CLASS, ProdutoVendido::class);
    }
}

// Uso:
$relatorio = new RelatorioRepositorio(Conexao::obter());

$top = $relatorio->produtosMaisVendidos(
    10,
    new DateTimeImmutable('first day of last month 00:00:00'),
    new DateTimeImmutable('last day of last month 23:59:59'),
);

foreach ($top as $produto) {
    echo "{$produto->nome}: {$produto->quantidade} un — {$produto->receitaFormatada()}\n";
}

Exercício 5

Desafio: implemente um QueryBuilder simples com interface fluente: select(string ...$cols), from(string $tabela), where(string $condicao, mixed $valor), orderBy(string $col, string $dir), limit(int $n), build(): array (retorna [$sql, $params]). Integre-o com PDO num método executar(\PDO $pdo): array.

Ver resposta

✓ Resposta: A regra que sustenta o builder inteiro: valor vira placeholder, identificador vira lista branca. Um builder que aceita nome de coluna vindo do usuário sem validar reabre a injeção de SQL exatamente onde o prepared statement não protege.

<?php

declare(strict_types=1);

final class QueryBuilder
{
    private array $colunas = ['*'];
    private string $tabela = '';
    private array $condicoes = [];
    private array $params = [];
    private array $ordenacao = [];
    private ?int $limite = null;
    private int $contador = 0;

    // Identificador (tabela, coluna) nunca vira placeholder — placeholder é
    // só para VALOR. Por isso o que é identificador passa por esta peneira.
    private const IDENTIFICADOR = '/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/';

    private function validarIdentificador(string $nome): string
    {
        if ($nome !== '*' && !preg_match(self::IDENTIFICADOR, $nome)) {
            throw new InvalidArgumentException("Identificador inválido: {$nome}");
        }
        return $nome;
    }

    public function select(string ...$cols): static
    {
        if ($cols !== []) {
            $this->colunas = array_map($this->validarIdentificador(...), $cols);
        }
        return $this;
    }

    public function from(string $tabela): static
    {
        $this->tabela = $this->validarIdentificador($tabela);
        return $this;
    }

    /** where('idade >=', 18) → "idade >= :p0", com 18 nos parâmetros */
    public function where(string $condicao, mixed $valor): static
    {
        if (!preg_match('/^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*(=|<>|!=|<=|>=|<|>|LIKE)\s*$/i',
                        $condicao, $m)) {
            throw new InvalidArgumentException("Condição inválida: {$condicao}");
        }

        $ph = ':p' . $this->contador++;
        $this->condicoes[] = "{$m[1]} " . strtoupper($m[2]) . " {$ph}";
        $this->params[$ph] = $valor;

        return $this;
    }

    public function orderBy(string $col, string $dir = 'ASC'): static
    {
        $this->ordenacao[] = $this->validarIdentificador($col) . ' '
            . (strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC');
        return $this;
    }

    public function limit(int $n): static
    {
        $this->limite = max(1, $n);
        return $this;
    }

    /** @return array{0: string, 1: array<string, mixed>} */
    public function build(): array
    {
        if ($this->tabela === '') {
            throw new LogicException('Chame from() antes de build().');
        }

        $sql = 'SELECT ' . implode(', ', $this->colunas) . ' FROM ' . $this->tabela;

        if ($this->condicoes !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $this->condicoes);
        }
        if ($this->ordenacao !== []) {
            $sql .= ' ORDER BY ' . implode(', ', $this->ordenacao);
        }
        if ($this->limite !== null) {
            // Já é int validado: interpolar aqui é seguro e evita o
            // problema do PARAM_INT em LIMIT.
            $sql .= ' LIMIT ' . $this->limite;
        }

        return [$sql, $this->params];
    }

    public function executar(\PDO $pdo): array
    {
        [$sql, $params] = $this->build();

        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);

        return $stmt->fetchAll();
    }
}

// Uso:
[$sql, $params] = (new QueryBuilder())
    ->select('id', 'nome', 'email')
    ->from('usuarios')
    ->where('ativo =', 1)
    ->where('criado_em >=', '2025-01-01')
    ->orderBy('nome')
    ->limit(20)
    ->build();

// SELECT id, nome, email FROM usuarios
//  WHERE ativo = :p0 AND criado_em >= :p1 ORDER BY nome ASC LIMIT 20
// [':p0' => 1, ':p1' => '2025-01-01']
Comentários

Mais em PHP

Introdução ao Laravel
Introdução ao Laravel

Instalação, estrutura de diretórios, rotas, controllers, Form Requests e o…

Blade — Templates no Laravel
Blade — Templates no Laravel

Sintaxe, herança de layouts, componentes com slots e formulários com erros de…

PostgreSQL com PHP
PostgreSQL com PHP

Conectar é só trocar o DSN, mas o que vem depois prende: RETURNING, UPSERT com…