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
-
Crie uma classe
Conexaocom método estáticoobter(): PDOque retorna uma instância singleton configurada com todos os atributos de produção discutidos no artigo. Use variáveis de ambiente para as credenciais. -
Implemente um
UsuarioRepositoriocom os métodosbuscarPorEmail(string $email): ?Usuario,buscarPorId(int $id): ?Usuario,criar(string $nome, string $email, string $senhaHash): UsuarioeatualizarUltimoLogin(int $id): void. Use prepared statements nomeados em todos os métodos. -
Implemente a função
transferirPontos(\PDO $pdo, int $deId, int $paraId, int $pontos): voidque usa uma transação para debitar pontos de um usuário e creditar em outro, garantindo que o saldo nunca fique negativo. -
Crie um
RelatorioRepositoriocom métodoprodutosMaisVendidos(int $limite, \DateTimeImmutable $de, \DateTimeImmutable $ate): arrayque retorna produtos com a soma de quantidades vendidas em um período. UseFETCH_CLASSpara hidratar os resultados. -
Desafio: implemente um
QueryBuildersimples 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étodoexecutar(\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