PHP

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

13 min de leitura

PDO Avançado, Prepared Statements e Padrão Repository
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 desenvolvedore

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
        \PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci,
                                          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 {
        // Bloqueia as duas linhas para evitar race condition
        $stmt = $pdo->prepare(
            "SELECT saldo FROM contas WHERE id = ? FOR UPDATE"
        );

        $stmt->execute([$deId]);
        $saldoOrigem = (float) $stmt->fetchColumn();

        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) {
        $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) {
        $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);
    }
}

Resumo do artigo

Conceito O que aprendemos
Atributos PDO ERRMODE_EXCEPTION, EMULATE_PREPARES=false, DEFAULT_FETCH_MODE
Prepared statements Parâmetros ? e :nome — SQL injection impossível
bindValue vs bindParam Por valor (imediato) vs por referência (na execução)
Modos de fetch FETCH_ASSOC, FETCH_CLASS, FETCH_KEY_PAIR, FETCH_COLUMN
Transações beginTransaction(), commit(), rollBack()
Savepoints Pontos de restauração parcial dentro de uma transação
Padrão Repository Isola acesso a dados — serviços dependem da interface, não de SQL
Hidratação Transforma arrays do banco em objetos de domínio tipados

Exercício da semana

  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.

  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.

  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.

  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.

  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.


Referências

  • PHP Manual — PDO: https://www.php.net/manual/pt_BR/book.pdo.php
  • PHP Manual — Prepared Statements: https://www.php.net/manual/pt_BR/pdo.prepared-statements.php
  • PHP Manual — PDO::FETCH_* constants: https://www.php.net/manual/pt_BR/pdo.constants.php
  • Fowler, M. — Patterns of Enterprise Application Architecture — Repository Pattern: https://www.martinfowler.com/eaaCatalog/repository.html
  • PHP: The Right Way — Databases: https://phptherightway.com/#databases
Comentários

Mais em PHP

Clean Architecture com PHP
Clean Architecture com PHP

Clean Architecture, formulada por Robert C. Martin, organiza o código em cama...

Testes Automatizados com PHPUnit
Testes Automatizados com PHPUnit

Testes automatizados são o mecanismo que permite modificar código com confian...

Estruturas de Repetição
Estruturas de Repetição

Se as estruturas de controle ensinam o programa a tomar decis&otilde;es, as e...