
Banco de Dados com PDO e MySQL: Queries Seguras, Transações e Repository Pattern Completo
Dominar PDO não é apenas uma boa prática — é a linha que separa aplicações profissionais de sistemas vulneráveis e frágeis.
1. Por que PDO e não MySQLi? {#por-que-pdo}
Antes de escrever uma linha de código, precisamos ter clareza sobre uma decisão arquitetural fundamental: por que PDO?
A extensão mysql_* foi depreciada no PHP 5.5 e removida no PHP 7.0. Isso deixou dois sucessores: MySQLi e PDO (PHP Data Objects). Muitos desenvolvedores chegam ao PDO por exclusão, sem entender por que ele é genuinamente superior na maioria dos cenários.
Abstração Real de Banco de Dados
O argumento mais forte do PDO é a abstração de driver. Com PDO, você pode trocar de MySQL para PostgreSQL, SQLite ou SQL Server alterando basicamente a string de conexão (DSN). Seu código de acesso a dados permanece intacto.
// MySQL
$dsn = 'mysql:host=localhost;dbname=loja;charset=utf8mb4';
// PostgreSQL — mesma interface, outro driver
$dsn = 'pgsql:host=localhost;dbname=loja';
// SQLite — perfeito para testes
$dsn = 'sqlite:/caminho/para/banco.db';
$pdo = new PDO($dsn, $usuario, $senha);
Isso não é apenas conveniência teórica. Na prática, significa que você pode usar SQLite nos testes automatizados e MySQL em produção, com o mesmo repositório de dados. Isso é uma vantagem arquitetural concreta.
Named Parameters vs. Positional
PDO suporta dois estilos de binding: posicional (?) e nominal (:nome). O MySQLi suporta apenas posicional. Para queries complexas com muitos parâmetros, os named parameters tornam o código dramaticamente mais legível e menos propenso a erros de ordem.
// MySQLi — posicional, propenso a erros de ordem
$stmt->bind_param('ssid', $nome, $email, $ativo, $id);
// PDO — nominal, autoexplicativo
$stmt->execute([
':nome' => $nome,
':email' => $email,
':ativo' => $ativo,
':id' => $id,
]);
Interface Orientada a Objetos Nativa
PDO foi projetado desde o início como uma API OO coesa. MySQLi tem uma interface procedural legada que coexiste com a OO, gerando inconsistência. Para projetos modernos com padrões como Repository e Active Record, PDO se encaixa naturalmente.
2. Configurando a Conexão PDO do Jeito Certo {#configurando-conexao}
A maioria dos tutoriais mostra uma conexão PDO básica. Vamos além e configuraremos uma conexão production-ready, com todas as opções relevantes definidas explicitamente.
A Classe de Conexão
<?php
declare(strict_types=1);
namespace App\Database;
use PDO;
use PDOException;
use RuntimeException;
final class DatabaseConnection
{
private static ?PDO $instance = null;
private function __construct() {}
private function __clone() {}
public static function getInstance(): PDO
{
if (self::$instance === null) {
self::$instance = self::createConnection();
}
return self::$instance;
}
private static function createConnection(): PDO
{
$config = self::loadConfig();
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
$options = [
// Lança PDOException em caso de erro — NUNCA use ERRMODE_SILENT
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
// Força o MySQL a usar prepared statements nativos
PDO::ATTR_EMULATE_PREPARES => false,
// Mantém os tipos nativos do MySQL (int permanece int, não string)
PDO::ATTR_STRINGIFY_FETCHES => false,
// Conexão persistente — reutiliza conexões no pool do servidor web
// Cuidado: pode causar problemas com transações em ambiente de testes
PDO::ATTR_PERSISTENT => $config['persistent'] ?? false,
// Timeout de conexão em segundos
PDO::ATTR_TIMEOUT => $config['timeout'] ?? 5,
// Opções específicas do MySQL
PDO::MYSQL_ATTR_FOUND_ROWS => true,
];
try {
$pdo = new PDO($dsn, $config['username'], $config['password'], $options);
// Define SQL mode seguro para evitar comportamentos silenciosos do MySQL
$pdo->exec("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO'");
return $pdo;
} catch (PDOException $e) {
// NUNCA exponha credenciais ou detalhes de conexão em produção
throw new RuntimeException(
'Falha ao conectar ao banco de dados. Tente novamente mais tarde.',
(int) $e->getCode(),
$e
);
}
}
private static function loadConfig(): array
{
return [
'host' => $_ENV['DB_HOST'] ?? 'localhost',
'port' => (int) ($_ENV['DB_PORT'] ?? 3306),
'database' => $_ENV['DB_DATABASE'] ?? throw new RuntimeException('DB_DATABASE não definido'),
'username' => $_ENV['DB_USERNAME'] ?? throw new RuntimeException('DB_USERNAME não definido'),
'password' => $_ENV['DB_PASSWORD'] ?? '',
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4',
];
}
}
Por Que charset=utf8mb4?
Nunca use charset=utf8 no MySQL. O charset utf8 do MySQL é, na verdade, um utf8mb3 — suporta apenas 3 bytes por caractere, o que exclui emojis e muitos caracteres asiáticos raros. O utf8mb4 é o UTF‑8 real (4 bytes), e você deve usá-lo em todos os projetos novos.
Além de definir no DSN, configure também no servidor MySQL:
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
Por Que ATTR_EMULATE_PREPARES = false?
Com emulação ativada (padrão), o PDO monta a query SQL manualmente no lado do PHP — o MySQL recebe uma string já interpolada. Com emulação desativada, o driver envia o template e os valores separadamente para o MySQL, que os processa de forma isolada. Isso oferece:
- Proteção real contra SQL injection (não apenas escape de strings)
- Tipos nativos preservados — o MySQL retorna
INTcomo inteiro PHP, não como string - Execução mais eficiente para queries executadas múltiplas vezes
A única desvantagem é que você não pode usar o mesmo named parameter duas vezes na mesma query com alguns drivers. Para MySQL, isso raramente é um problema prático.
3. Prepared Statements: A Base de Tudo {#prepared-statements}
Prepared statements são o coração do PDO seguro. A ideia é simples: separar estrutura de dados.
Em vez de construir a query com os dados do usuário embutidos, você envia um template com placeholders e depois fornece os valores separadamente. O banco de dados nunca confunde dado com instrução SQL.
Anatomia de um Prepared Statement
<?php
$pdo = DatabaseConnection::getInstance();
// 1. PREPARA: envia o template SQL para o MySQL
// O MySQL compila e otimiza a query neste momento
$stmt = $pdo->prepare(
'SELECT id, nome, email, criado_em
FROM usuarios
WHERE email = :email AND ativo = :ativo
LIMIT 1'
);
// 2. EXECUTA: envia os valores — o MySQL os trata como dados puros
$stmt->execute([
':email' => $email,
':ativo' => 1,
]);
// 3. RECUPERA: obtém o resultado
$usuario = $stmt->fetch();
Binding Explícito de Tipos
Para máxima segurança e performance, você pode fazer binding explícito especificando o tipo do parâmetro:
$stmt = $pdo->prepare(
'UPDATE produtos SET estoque = :estoque, preco = :preco WHERE id = :id'
);
// bindValue: liga o valor atual da variável (copiado neste momento)
$stmt->bindValue(':estoque', $estoque, PDO::PARAM_INT);
$stmt->bindValue(':preco', $preco, PDO::PARAM_STR); // floats vão como string no PDO
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
Diferença crucial entre bindValue e bindParam:
// bindParam: liga a REFERÊNCIA da variável
// O valor é lido no momento do execute(), não do bind
$stmt->bindParam(':nome', $nome, PDO::PARAM_STR);
$nome = 'João'; // Mudou após o bind — é este valor que será usado
$stmt->execute(); // Envia 'João'
// bindValue: copia o valor no momento do bind
$stmt->bindValue(':nome', $nome, PDO::PARAM_STR);
$nome = 'Maria'; // Mudança ignorada
$stmt->execute(); // Ainda envia o valor original
bindParam é útil em loops onde você precisa reusar o statement com variáveis que mudam:
$stmt = $pdo->prepare('INSERT INTO logs (mensagem, nivel) VALUES (:mensagem, :nivel)');
$stmt->bindParam(':mensagem', $mensagem, PDO::PARAM_STR);
$stmt->bindParam(':nivel', $nivel, PDO::PARAM_INT);
foreach ($eventos as $evento) {
$mensagem = $evento['mensagem'];
$nivel = $evento['nivel'];
$stmt->execute(); // Lê os valores atuais de $mensagem e $nivel
}
4. SQL Injection: Como PDO te Protege {#sql-injection}
SQL injection continua sendo a vulnerabilidade mais explorada na web, aparecendo consistentemente no topo do OWASP Top 10. Entender mecanicamente por que prepared statements previnem isso — não apenas que previnem — é essencial.
A Anatomia de um Ataque
Considere este código vulnerável:
// NUNCA faça isso
$id = $_GET['id']; // Usuário envia: "1 OR 1=1 --"
$query = "SELECT * FROM usuarios WHERE id = $id";
$resultado = $pdo->query($query);
// Query executada: SELECT * FROM usuarios WHERE id = 1 OR 1=1 --
// Resultado: TODOS os usuários são retornados
Uma entrada mais destrutiva:
1; DROP TABLE usuarios; --
Com concatenação direta, esta string vira instrução SQL executável.
Por Que Prepared Statements São Imunes
Com ATTR_EMULATE_PREPARES = false, o fluxo é:
- Fase 1 — Parse: MySQL recebe
SELECT * FROM usuarios WHERE id = ?e cria um plano de execução. Neste ponto, a estrutura SQL está fixada. - Fase 2 — Bind: O valor do usuário (
1 OR 1=1 --) é enviado como dado puro, vinculado ao placeholder?. - Fase 3 — Execute: MySQL executa com o valor literalmente como o conteúdo do campo
id. A string1 OR 1=1 --é tratada como um valor de string, não como código SQL.
O MySQL literalmente não tem como confundir dado com instrução porque eles chegam em canais separados.
Casos Onde Você Ainda Precisa de Cuidado
Prepared statements não resolvem tudo. Há casos onde você manipula partes da query que não podem ser parametrizadas:
// Nomes de tabelas e colunas NÃO podem ser parametrizados
// PDO não suporta: ORDER BY :coluna
// Você precisa de whitelist:
function buildOrderClause(string $coluna, string $direcao): string
{
$colunasPermitidas = ['nome', 'email', 'criado_em', 'preco'];
$direcoesPermitidas = ['ASC', 'DESC'];
if (!in_array($coluna, $colunasPermitidas, true)) {
throw new InvalidArgumentException("Coluna inválida: $coluna");
}
if (!in_array(strtoupper($direcao), $direcoesPermitidas, true)) {
throw new InvalidArgumentException("Direção inválida: $direcao");
}
// Seguro: apenas valores validados chegam aqui
return "ORDER BY `$coluna` $direcao";
}
Regra de ouro: se a parte da query não pode ser um prepared statement placeholder (nomes de tabelas, colunas, cláusulas ORDER BY, LIMIT dinâmico), use whitelist de valores permitidos, nunca blacklist ou escape.
5. Tipos de Fetch e Manipulação de Resultados {#tipos-de-fetch}
PDO oferece múltiplos modos de fetch, cada um adequado a um cenário diferente.
FETCH_ASSOC — O Padrão Recomendado
$stmt->execute();
$linha = $stmt->fetch(PDO::FETCH_ASSOC);
// ['id' => 1, 'nome' => 'João', 'email' => 'joao@email.com']
$todas = $stmt->fetchAll(PDO::FETCH_ASSOC);
// [['id' => 1, ...], ['id' => 2, ...], ...]
FETCH_OBJ — Objeto Simples
$usuario = $stmt->fetch(PDO::FETCH_OBJ);
echo $usuario->nome; // Acesso via propriedade
FETCH_CLASS — Hidratação em Entidades
Este é o modo mais poderoso para arquiteturas orientadas a objetos:
class Usuario
{
public int $id;
public string $nome;
public string $email;
public DateTimeImmutable $criado_em;
public function __construct()
{
// PDO chama o construtor APÓS preencher as propriedades
// Converta tipos aqui se necessário
if (is_string($this->criado_em)) {
$this->criado_em = new DateTimeImmutable($this->criado_em);
}
}
public function isAtivo(): bool
{
return $this->ativo === 1;
}
}
$stmt = $pdo->prepare('SELECT id, nome, email, criado_em FROM usuarios WHERE id = :id');
$stmt->execute([':id' => $id]);
// PDO instancia Usuario e mapeia colunas para propriedades automaticamente
$stmt->setFetchMode(PDO::FETCH_CLASS, Usuario::class);
$usuario = $stmt->fetch();
var_dump($usuario instanceof Usuario); // true
echo $usuario->nome;
FETCH_COLUMN — Coluna Única
$stmt = $pdo->prepare('SELECT email FROM usuarios WHERE ativo = 1');
$stmt->execute();
// Retorna array com apenas os valores da primeira coluna
$emails = $stmt->fetchAll(PDO::FETCH_COLUMN);
// ['user1@email.com', 'user2@email.com', ...]
FETCH_KEY_PAIR — Array Chave-Valor
$stmt = $pdo->prepare('SELECT id, nome FROM categorias ORDER BY nome');
$stmt->execute();
// Retorna [id => nome, id => nome, ...]
$categorias = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
// [1 => 'Eletrônicos', 2 => 'Roupas', 3 => 'Livros']
Extremamente útil para popular <select> em formulários.
fetchColumn() — Valor Escalar Único
$stmt = $pdo->prepare('SELECT COUNT(*) FROM pedidos WHERE usuario_id = :uid');
$stmt->execute([':uid' => $usuarioId]);
$total = $stmt->fetchColumn(); // Retorna o inteiro diretamente
6. Transações com PDO {#transacoes}
Transações são o mecanismo que garante a atomicidade das operações no banco de dados. Ou todas as operações de um grupo são confirmadas (commit), ou nenhuma é (rollback). Sem transações, seu sistema pode ficar em estado inconsistente após uma falha parcial.
O Problema Sem Transações
Imagine processar um pedido de e‑commerce:
1. Debitar estoque do produto
2. Criar registro do pedido
3. Registrar itens do pedido
4. Atualizar saldo do usuário (pontos de fidelidade)
Se o servidor cair após o passo 2, o estoque foi debitado mas o pedido não existe. O produto sumiu do estoque sem gerar receita. Caos.
Implementação Correta de Transações
<?php
function processarPedido(PDO $pdo, array $dados): int
{
$pdo->beginTransaction();
try {
// 1. Verificar e debitar estoque
$stmtEstoque = $pdo->prepare(
'UPDATE produtos
SET estoque = estoque - :quantidade
WHERE id = :produto_id AND estoque >= :quantidade'
);
foreach ($dados['itens'] as $item) {
$stmtEstoque->execute([
':quantidade' => $item['quantidade'],
':produto_id' => $item['produto_id'],
]);
if ($stmtEstoque->rowCount() === 0) {
// rowCount() = 0 significa que o WHERE falhou (estoque insuficiente)
throw new RuntimeException(
"Estoque insuficiente para o produto {$item['produto_id']}"
);
}
}
// 2. Criar o pedido
$stmtPedido = $pdo->prepare(
'INSERT INTO pedidos (usuario_id, total, status, criado_em)
VALUES (:usuario_id, :total, :status, NOW())'
);
$stmtPedido->execute([
':usuario_id' => $dados['usuario_id'],
':total' => $dados['total'],
':status' => 'pendente',
]);
$pedidoId = (int) $pdo->lastInsertId();
// 3. Inserir itens do pedido
$stmtItem = $pdo->prepare(
'INSERT INTO pedido_itens (pedido_id, produto_id, quantidade, preco_unitario)
VALUES (:pedido_id, :produto_id, :quantidade, :preco)'
);
foreach ($dados['itens'] as $item) {
$stmtItem->execute([
':pedido_id' => $pedidoId,
':produto_id' => $item['produto_id'],
':quantidade' => $item['quantidade'],
':preco' => $item['preco_unitario'],
]);
}
// 4. Atualizar pontos de fidelidade
$pdo->prepare(
'UPDATE usuarios SET pontos = pontos + :pontos WHERE id = :id'
)->execute([
':pontos' => (int) floor($dados['total']),
':id' => $dados['usuario_id'],
]);
// Tudo correu bem — confirma TODAS as operações atomicamente
$pdo->commit();
return $pedidoId;
} catch (Throwable $e) {
// Qualquer erro — reverte TODAS as operações
$pdo->rollBack();
// Re-lança a exceção para o chamador tratar
throw $e;
}
}
Savepoints: Transações Aninhadas
PDO não suporta transações verdadeiramente aninhadas, mas você pode simular com savepoints do MySQL:
function processarComSavepoint(PDO $pdo): void
{
$pdo->beginTransaction();
try {
// Operação principal
$pdo->exec("INSERT INTO log_auditoria (acao) VALUES ('inicio_processo')");
// Tenta operação opcional (não fatal se falhar)
$pdo->exec("SAVEPOINT operacao_opcional");
try {
$pdo->exec("INSERT INTO cache_externo VALUES (...)");
} catch (PDOException $e) {
// Falha no cache não é fatal — reverte apenas até o savepoint
$pdo->exec("ROLLBACK TO SAVEPOINT operacao_opcional");
}
$pdo->exec("RELEASE SAVEPOINT operacao_opcional");
// Continua com operações críticas
$pdo->exec("UPDATE status SET atual = 'processado'");
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
Verificando o Estado da Transação
// Sempre verifique se já está em transação antes de iniciar outra
if (!$pdo->inTransaction()) {
$pdo->beginTransaction();
}
Isso é especialmente importante em código que pode ser chamado dentro ou fora de uma transação existente.
7. Tratamento de Erros e Exceções {#tratamento-erros}
Hierarquia de Exceções PDO
\Exception
└── \RuntimeException
└── \PDOException
PDOException expõe:
getMessage()— mensagem de errogetCode()— SQLSTATE (string de 5 caracteres, ex:'23000'para violação de constraint)errorInfo— array com[SQLSTATE, código driver, mensagem driver]
Estratégia de Tratamento Profissional
<?php
declare(strict_types=1);
namespace App\Database;
use PDOException;
use App\Exceptions\DatabaseException;
use App\Exceptions\DuplicateEntryException;
use App\Exceptions\ForeignKeyViolationException;
final class ExceptionConverter
{
// SQLSTATE codes do MySQL mais comuns
private const DUPLICATE_ENTRY = '23000';
private const FK_CONSTRAINT = '23000';
private const TABLE_NOT_FOUND = '42S02';
private const COLUMN_NOT_FOUND = '42S22';
private const DEADLOCK = '40001';
// Códigos de erro específicos do MySQL (driver code)
private const MYSQL_DUPLICATE_KEY = 1062;
private const MYSQL_FK_VIOLATION = 1452;
private const MYSQL_DEADLOCK = 1213;
public static function convert(PDOException $e): DatabaseException
{
$driverCode = (int) ($e->errorInfo[1] ?? 0);
return match($driverCode) {
self::MYSQL_DUPLICATE_KEY => new DuplicateEntryException(
'Registro duplicado: já existe um registro com estes dados.',
previous: $e
),
self::MYSQL_FK_VIOLATION => new ForeignKeyViolationException(
'Violação de integridade referencial.',
previous: $e
),
self::MYSQL_DEADLOCK => new DeadlockException(
'Deadlock detectado. Tente novamente.',
previous: $e
),
default => new DatabaseException(
'Erro ao acessar o banco de dados.',
previous: $e
),
};
}
}
Uso no repositório:
try {
$stmt->execute($params);
} catch (PDOException $e) {
throw ExceptionConverter::convert($e);
}
Assim, a camada de aplicação lida com DuplicateEntryException de forma semântica, sem conhecer detalhes do banco de dados.
8. Repository Pattern: Teoria e Prática Completa {#repository-pattern}
O Repository Pattern é um dos padrões de design mais importantes para aplicações com acesso a banco de dados. Ele cria uma camada de abstração entre a lógica de negócio e a infraestrutura de persistência.
Por Que Repository Pattern?
Sem Repository Pattern, sua lógica de negócio frequentemente fica assim:
// Controlador com SQL direto — ANTIPADRÃO
class PedidoController
{
public function criar(Request $request): Response
{
$pdo = DatabaseConnection::getInstance();
$stmt = $pdo->prepare('SELECT * FROM usuarios WHERE id = ?');
// ... SQL espalhado pelo código de negócio
}
}
Problemas:
- SQL espalhado por toda a aplicação
- Impossível testar sem banco de dados real
- Trocar de banco de dados requer reescrever lógica de negócio
- Violação do princípio da responsabilidade única
A Arquitetura do Repository Pattern
┌─────────────────────────────────────────────────────────┐
│ Camada de Negócio │
│ (Services, Use Cases, etc.) │
└─────────────────────┬───────────────────────────────────┘
│ depende apenas da interface
▼
┌─────────────────────────────────────────────────────────┐
│ Interface do Repositório │
│ (Contrato puro — sem PDO, sem SQL) │
└─────────────────────┬───────────────────────────────────┘
│ implementado por
▼
┌─────────────────────────────────────────────────────────┐
│ Implementação PDO do Repositório │
│ (Toda a infraestrutura de banco aqui) │
└─────────────────────────────────────────────────────────┘
Passo 1: A Entidade
<?php
declare(strict_types=1);
namespace App\Domain\Product;
use DateTimeImmutable;
use InvalidArgumentException;
final class Product
{
public function __construct(
public readonly ?int $id,
public readonly string $nome,
public readonly string $descricao,
public readonly float $preco,
public readonly int $estoque,
public readonly int $categoriaId,
public readonly bool $ativo,
public readonly DateTimeImmutable $criadoEm,
public readonly DateTimeImmutable $atualizadoEm,
) {
$this->validar();
}
private function validar(): void
{
if (empty(trim($this->nome))) {
throw new InvalidArgumentException('Nome do produto não pode ser vazio.');
}
if ($this->preco < 0) {
throw new InvalidArgumentException('Preço não pode ser negativo.');
}
if ($this->estoque < 0) {
throw new InvalidArgumentException('Estoque não pode ser negativo.');
}
}
public function withEstoque(int $novoEstoque): self
{
return new self(
$this->id,
$this->nome,
$this->descricao,
$this->preco,
$novoEstoque,
$this->categoriaId,
$this->ativo,
$this->criadoEm,
new DateTimeImmutable(),
);
}
public function temEstoqueDisponivel(int $quantidade): bool
{
return $this->estoque >= $quantidade;
}
}
Passo 2: A Interface do Repositório
<?php
declare(strict_types=1);
namespace App\Domain\Product;
interface ProductRepositoryInterface
{
public function findById(int $id): ?Product;
public function findAll(ProductFilter $filter): ProductCollection;
public function findByCategoria(int $categoriaId): ProductCollection;
public function save(Product $product): Product;
public function delete(int $id): bool;
public function existsByNome(string $nome, ?int $excluirId = null): bool;
public function countByCategoria(int $categoriaId): int;
}
Note que a interface não menciona PDO, SQL ou banco de dados em lugar algum. Ela fala apenas em termos de domínio.
Passo 3: Value Objects de Suporte
<?php
declare(strict_types=1);
namespace App\Domain\Product;
final class ProductFilter
{
public function __construct(
public readonly ?string $busca = null,
public readonly ?int $categoriaId = null,
public readonly ?bool $apenasAtivos = true,
public readonly float $precoMin = 0,
public readonly float $precoMax = PHP_FLOAT_MAX,
public readonly string $ordenarPor = 'nome',
public readonly string $direcao = 'ASC',
public readonly int $pagina = 1,
public readonly int $porPagina = 20,
) {}
public function getOffset(): int
{
return ($this->pagina - 1) * $this->porPagina;
}
}
final class ProductCollection implements \Countable, \IteratorAggregate
{
private array $items;
public function __construct(
private readonly array $products,
private readonly int $total,
) {
$this->items = $products;
}
public function count(): int
{
return count($this->items);
}
public function getTotal(): int
{
return $this->total;
}
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->items);
}
public function isEmpty(): bool
{
return empty($this->items);
}
public function map(callable $callback): array
{
return array_map($callback, $this->items);
}
}
Passo 4: A Implementação PDO
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\PDO;
use PDO;
use PDOException;
use DateTimeImmutable;
use App\Domain\Product\{
Product,
ProductCollection,
ProductFilter,
ProductRepositoryInterface
};
use App\Database\ExceptionConverter;
final class PDOProductRepository implements ProductRepositoryInterface
{
private const COLUNAS_ORDENACAO_PERMITIDAS = [
'nome', 'preco', 'estoque', 'criado_em', 'atualizado_em'
];
public function __construct(
private readonly PDO $pdo
) {}
public function findById(int $id): ?Product
{
$stmt = $this->pdo->prepare(
'SELECT p.id, p.nome, p.descricao, p.preco, p.estoque,
p.categoria_id, p.ativo, p.criado_em, p.atualizado_em
FROM produtos p
WHERE p.id = :id'
);
try {
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row ? $this->hydrate($row) : null;
} catch (PDOException $e) {
throw ExceptionConverter::convert($e);
}
}
public function findAll(ProductFilter $filter): ProductCollection
{
[$sql, $params] = $this->buildFilteredQuery($filter);
// Conta o total sem LIMIT para paginação
$countSql = "SELECT COUNT(*) FROM ($sql) AS subquery";
$countStmt = $this->pdo->prepare($countSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
// Adiciona ordenação e paginação
$coluna = $this->sanitizeOrderColumn($filter->ordenarPor);
$direcao = strtoupper($filter->direcao) === 'DESC' ? 'DESC' : 'ASC';
$sql .= " ORDER BY p.`$coluna` $direcao";
$sql .= ' LIMIT :limit OFFSET :offset';
$stmt = $this->pdo->prepare($sql);
// Parâmetros de filtro
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
// Parâmetros de paginação (devem ser inteiros)
$stmt->bindValue(':limit', $filter->porPagina, PDO::PARAM_INT);
$stmt->bindValue(':offset', $filter->getOffset(), PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
$products = array_map([$this, 'hydrate'], $rows);
return new ProductCollection($products, $total);
}
public function findByCategoria(int $categoriaId): ProductCollection
{
$filter = new ProductFilter(categoriaId: $categoriaId);
return $this->findAll($filter);
}
public function save(Product $product): Product
{
if ($product->id === null) {
return $this->insert($product);
}
return $this->update($product);
}
private function insert(Product $product): Product
{
$stmt = $this->pdo->prepare(
'INSERT INTO produtos
(nome, descricao, preco, estoque, categoria_id, ativo, criado_em, atualizado_em)
VALUES
(:nome, :descricao, :preco, :estoque, :categoria_id, :ativo, :criado_em, :atualizado_em)'
);
$agora = new DateTimeImmutable();
try {
$stmt->execute([
':nome' => $product->nome,
':descricao' => $product->descricao,
':preco' => $product->preco,
':estoque' => $product->estoque,
':categoria_id' => $product->categoriaId,
':ativo' => (int) $product->ativo,
':criado_em' => $agora->format('Y-m-d H:i:s'),
':atualizado_em' => $agora->format('Y-m-d H:i:s'),
]);
} catch (PDOException $e) {
throw ExceptionConverter::convert($e);
}
$novoId = (int) $this->pdo->lastInsertId();
return new Product(
$novoId,
$product->nome,
$product->descricao,
$product->preco,
$product->estoque,
$product->categoriaId,
$product->ativo,
$agora,
$agora,
);
}
private function update(Product $product): Product
{
$stmt = $this->pdo->prepare(
'UPDATE produtos
SET nome = :nome,
descricao = :descricao,
preco = :preco,
estoque = :estoque,
categoria_id = :categoria_id,
ativo = :ativo,
atualizado_em = :atualizado_em
WHERE id = :id'
);
$agora = new DateTimeImmutable();
try {
$stmt->execute([
':nome' => $product->nome,
':descricao' => $product->descricao,
':preco' => $product->preco,
':estoque' => $product->estoque,
':categoria_id' => $product->categoriaId,
':ativo' => (int) $product->ativo,
':atualizado_em' => $agora->format('Y-m-d H:i:s'),
':id' => $product->id,
]);
} catch (PDOException $e) {
throw ExceptionConverter::convert($e);
}
return new Product(
$product->id,
$product->nome,
$product->descricao,
$product->preco,
$product->estoque,
$product->categoriaId,
$product->ativo,
$product->criadoEm,
$agora,
);
}
public function delete(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM produtos WHERE id = :id');
try {
$stmt->execute([':id' => $id]);
} catch (PDOException $e) {
throw ExceptionConverter::convert($e);
}
return $stmt->rowCount() > 0;
}
public function existsByNome(string $nome, ?int $excluirId = null): bool
{
if ($excluirId !== null) {
$stmt = $this->pdo->prepare(
'SELECT EXISTS(SELECT 1 FROM produtos WHERE nome = :nome AND id != :id)'
);
$stmt->execute([':nome' => $nome, ':id' => $excluirId]);
} else {
$stmt = $this->pdo->prepare(
'SELECT EXISTS(SELECT 1 FROM produtos WHERE nome = :nome)'
);
$stmt->execute([':nome' => $nome]);
}
return (bool) $stmt->fetchColumn();
}
public function countByCategoria(int $categoriaId): int
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM produtos WHERE categoria_id = :categoria_id AND ativo = 1'
);
$stmt->execute([':categoria_id' => $categoriaId]);
return (int) $stmt->fetchColumn();
}
// -------------------------------------------------------------------------
// Métodos Privados de Suporte
// -------------------------------------------------------------------------
private function buildFilteredQuery(ProductFilter $filter): array
{
$sql = 'SELECT p.* FROM produtos p WHERE 1=1';
$params = [];
if ($filter->apenasAtivos) {
$sql .= ' AND p.ativo = 1';
}
if ($filter->busca !== null && $filter->busca !== '') {
$sql .= ' AND (p.nome LIKE :busca OR p.descricao LIKE :busca)';
$params[':busca'] = '%' . $filter->busca . '%';
}
if ($filter->categoriaId !== null) {
$sql .= ' AND p.categoria_id = :categoria_id';
$params[':categoria_id'] = $filter->categoriaId;
}
if ($filter->precoMin > 0) {
$sql .= ' AND p.preco >= :preco_min';
$params[':preco_min'] = $filter->precoMin;
}
if ($filter->precoMax < PHP_FLOAT_MAX) {
$sql .= ' AND p.preco <= :preco_max';
$params[':preco_max'] = $filter->precoMax;
}
return [$sql, $params];
}
private function sanitizeOrderColumn(string $coluna): string
{
if (!in_array($coluna, self::COLUNAS_ORDENACAO_PERMITIDAS, true)) {
return 'nome'; // fallback seguro
}
return $coluna;
}
private function hydrate(array $row): Product
{
return new Product(
id: (int) $row['id'],
nome: $row['nome'],
descricao: $row['descricao'],
preco: (float) $row['preco'],
estoque: (int) $row['estoque'],
categoriaId: (int) $row['categoria_id'],
ativo: (bool) $row['ativo'],
criadoEm: new DateTimeImmutable($row['criado_em']),
atualizadoEm: new DateTimeImmutable($row['atualizado_em']),
);
}
}
9. Exemplo Real: Sistema de E‑commerce com Repository {#exemplo-real}
Vamos conectar tudo num caso de uso concreto: o ProductService que usa o repositório através da interface.
<?php
declare(strict_types=1);
namespace App\Application\Product;
use App\Domain\Product\{
Product,
ProductCollection,
ProductFilter,
ProductRepositoryInterface
};
use App\Domain\Exceptions\{
ProductNotFoundException,
DuplicateProductNameException
};
use DateTimeImmutable;
final class ProductService
{
public function __construct(
private readonly ProductRepositoryInterface $repository
) {}
public function criar(CreateProductDTO $dto): Product
{
if ($this->repository->existsByNome($dto->nome)) {
throw new DuplicateProductNameException(
"Já existe um produto com o nome '{$dto->nome}'."
);
}
$product = new Product(
id: null,
nome: $dto->nome,
descricao: $dto->descricao,
preco: $dto->preco,
estoque: $dto->estoqueInicial,
categoriaId: $dto->categoriaId,
ativo: true,
criadoEm: new DateTimeImmutable(),
atualizadoEm: new DateTimeImmutable(),
);
return $this->repository->save($product);
}
public function atualizar(int $id, UpdateProductDTO $dto): Product
{
$product = $this->repository->findById($id);
if ($product === null) {
throw new ProductNotFoundException("Produto #$id não encontrado.");
}
if ($this->repository->existsByNome($dto->nome, $id)) {
throw new DuplicateProductNameException(
"Já existe outro produto com o nome '{$dto->nome}'."
);
}
$produtoAtualizado = new Product(
id: $product->id,
nome: $dto->nome,
descricao: $dto->descricao,
preco: $dto->preco,
estoque: $product->estoque, // estoque gerenciado separadamente
categoriaId: $dto->categoriaId,
ativo: $dto->ativo,
criadoEm: $product->criadoEm,
atualizadoEm: new DateTimeImmutable(),
);
return $this->repository->save($produtoAtualizado);
}
public function listar(ProductFilter $filter): ProductCollection
{
return $this->repository->findAll($filter);
}
public function excluir(int $id): void
{
$product = $this->repository->findById($id);
if ($product === null) {
throw new ProductNotFoundException("Produto #$id não encontrado.");
}
$this->repository->delete($id);
}
}
Note que ProductService não sabe que existe PDO, MySQL ou qualquer banco de dados. Ele trabalha apenas com ProductRepositoryInterface. Isso é o desacoplamento real.
Container de Injeção de Dependência
// bootstrap/container.php
$container->bind(
ProductRepositoryInterface::class,
fn() => new PDOProductRepository(DatabaseConnection::getInstance())
);
// Em testes, você pode fazer:
$container->bind(
ProductRepositoryInterface::class,
fn() => new InMemoryProductRepository() // sem banco de dados!
);
10. Performance: Otimizações Avançadas com PDO {#performance}
Inserção em Massa com Transações
Inserir 10.000 registros um a um é lento. A combinação de prepared statement + transação é dramaticamente mais rápida:
function inserirEmMassa(PDO $pdo, array $registros): void
{
$stmt = $pdo->prepare(
'INSERT INTO importacao_dados (campo1, campo2, campo3)
VALUES (:campo1, :campo2, :campo3)'
);
$pdo->beginTransaction();
try {
$contador = 0;
foreach ($registros as $registro) {
$stmt->execute([
':campo1' => $registro['campo1'],
':campo2' => $registro['campo2'],
':campo3' => $registro['campo3'],
]);
$contador++;
// Commit parcial a cada 1000 registros
// Evita transações enormes que bloqueiam tabelas por muito tempo
if ($contador % 1000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
IN Clause Dinâmica Segura
Um problema clássico com PDO: como parametrizar WHERE id IN (1, 2, 3) quando a lista é dinâmica?
function findByIds(PDO $pdo, array $ids): array
{
if (empty($ids)) {
return [];
}
// Garante que todos são inteiros (extra de segurança)
$ids = array_map('intval', $ids);
// Cria placeholders: ?, ?, ?
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare(
"SELECT * FROM produtos WHERE id IN ($placeholders)"
);
$stmt->execute($ids);
return $stmt->fetchAll();
}
11. Testes Unitários com Repository Pattern {#testes}
Um dos maiores benefícios do Repository Pattern é a testabilidade. Você pode criar implementações in-memory para testes unitários rápidos, sem banco de dados.
<?php
declare(strict_types=1);
namespace App\Tests\Infrastructure;
use App\Domain\Product\{
Product,
ProductCollection,
ProductFilter,
ProductRepositoryInterface
};
final class InMemoryProductRepository implements ProductRepositoryInterface
{
private array $products = [];
private int $nextId = 1;
public function findById(int $id): ?Product
{
return $this->products[$id] ?? null;
}
public function findAll(ProductFilter $filter): ProductCollection
{
$items = array_values($this->products);
if ($filter->apenasAtivos) {
$items = array_filter($items, fn(Product $p) => $p->ativo);
}
if ($filter->busca) {
$items = array_filter(
$items,
fn(Product $p) => str_contains(strtolower($p->nome), strtolower($filter->busca))
);
}
$total = count($items);
$items = array_slice(
array_values($items),
$filter->getOffset(),
$filter->porPagina
);
return new ProductCollection($items, $total);
}
public function findByCategoria(int $categoriaId): ProductCollection
{
return $this->findAll(new ProductFilter(categoriaId: $categoriaId));
}
public function save(Product $product): Product
{
if ($product->id === null) {
$id = $this->nextId++;
$saved = new Product(
$id,
$product->nome,
$product->descricao,
$product->preco,
$product->estoque,
$product->categoriaId,
$product->ativo,
$product->criadoEm,
$product->atualizadoEm,
);
$this->products[$id] = $saved;
return $saved;
}
$this->products[$product->id] = $product;
return $product;
}
public function delete(int $id): bool
{
if (!isset($this->products[$id])) {
return false;
}
unset($this->products[$id]);
return true;
}
public function existsByNome(string $nome, ?int $excluirId = null): bool
{
foreach ($this->products as $product) {
if ($product->nome === $nome && $product->id !== $excluirId) {
return true;
}
}
return false;
}
public function countByCategoria(int $categoriaId): int
{
return count(array_filter(
$this->products,
fn(Product $p) => $p->categoriaId === $categoriaId && $p->ativo
));
}
}
Teste Unitário do Service
<?php
use PHPUnit\Framework\TestCase;
use App\Application\Product\{ProductService, CreateProductDTO};
use App\Tests\Infrastructure\InMemoryProductRepository;
use App\Domain\Exceptions\DuplicateProductNameException;
final class ProductServiceTest extends TestCase
{
private ProductService $service;
private InMemoryProductRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryProductRepository();
$this->service = new ProductService($this->repository);
}
public function test_criar_produto_com_sucesso(): void
{
$dto = new CreateProductDTO(
nome: 'Notebook Pro',
descricao: 'Notebook de alta performance',
preco: 5999.90,
estoqueInicial: 10,
categoriaId: 1,
);
$produto = $this->service->criar($dto);
$this->assertNotNull($produto->id);
$this->assertEquals('Notebook Pro', $produto->nome);
$this->assertEquals(5999.90, $produto->preco);
$this->assertTrue($produto->ativo);
}
public function test_nao_permite_nome_duplicado(): void
{
$dto = new CreateProductDTO('Notebook Pro', '', 1000, 5, 1);
$this->service->criar($dto);
$this->expectException(DuplicateProductNameException::class);
// Tenta criar outro com o mesmo nome
$this->service->criar($dto);
}
public function test_listar_produtos_com_filtro_de_busca(): void
{
$this->service->criar(new CreateProductDTO('Notebook Pro', '', 1000, 5, 1));
$this->service->criar(new CreateProductDTO('Mouse Gamer', '', 200, 20, 2));
$this->service->criar(new CreateProductDTO('Notebook Air', '', 8000, 3, 1));
$filter = new ProductFilter(busca: 'notebook');
$collection = $this->service->listar($filter);
$this->assertEquals(2, $collection->getTotal());
}
}
Esses testes rodam em milissegundos, sem nenhuma conexão com banco de dados. O Repository Pattern tornou isso possível.
12. Checklist de Segurança Final {#checklist}
Configuração de Conexão
- [ ]
ATTR_ERRMODE => ERRMODE_EXCEPTIONativado - [ ]
ATTR_EMULATE_PREPARES => falsepara MySQL - [ ] Charset
utf8mb4no DSN e no servidor - [ ] Credenciais em variáveis de ambiente, nunca no código-fonte
- [ ] SQL mode estrito ativado na sessão
- [ ] Erros de conexão não expõem credenciais ao usuário
Queries e Parâmetros
- [ ] Zero concatenação de variáveis externas em SQL
- [ ] Todos os valores de usuário via prepared statements
- [ ] Nomes de tabelas/colunas dinâmicos usam whitelist
- [ ] Cláusulas ORDER BY dinâmicas sanitizadas
- [ ] LIMIT e OFFSET via
PDO::PARAM_INT
Transações
- [ ] Todo grupo de operações relacionadas envolto em transação
- [ ]
rollBack()garantido em blococatch - [ ]
rowCount()verificado após UPDATE/DELETE críticos - [ ]
lastInsertId()validado após INSERT
Arquitetura
- [ ] Repository Pattern implementado com interface
- [ ] Service layer não tem dependências de infraestrutura de banco
- [ ] Exceções de banco convertidas para exceções de domínio
- [ ] Implementação in-memory disponível para testes
- [ ] Nenhum SQL no código de negócio
Erros e Logging
- [ ] Mensagens de erro para usuário não expõem stack trace
- [ ] Erros de banco logados com contexto suficiente para debug
- [ ] SQLSTATE e driver code capturados e categorizados
- [ ] Deadlocks tratados com retry automático onde aplicável
Conclusão
PDO não é apenas uma ferramenta — é uma filosofia de acesso a dados. Cada escolha que exploramos neste artigo tem uma razão arquitetural clara:
Prepared statements eliminam SQL injection na raiz, não como patch, mas como consequência natural de separar estrutura de dados. ATTR_EMULATE_PREPARES = false garante que essa separação aconteça de verdade, no nível do protocolo de banco de dados. Transações transformam grupos de operações frágeis em unidades atômicas confiáveis. E o Repository Pattern garante que nenhuma dessas decisões de infraestrutura contamine a lógica de negócio — que permanece limpa, testável e independente.
A distância entre um sistema que “funciona” e um que é seguro, testável e maintainável é exatamente esta pilha de decisões. Agora você tem todas elas.
Artigo produzido com profundidade técnica para desenvolvedores PHP que buscam construir sistemas robustos e seguros em produção.