Conceitos: persistência, PDO e a fronteira do repositório
Persistência é a parte da aplicação responsável por gravar e ler dados de um armazenamento durável (normalmente um banco relacional). Em uma arquitetura limpa, o restante do sistema não deve “saber” como os dados são armazenados (SQL, tabelas, joins). Para isso, usamos repositórios como fronteira: o domínio e os casos de uso conversam com interfaces (contratos), e a implementação concreta usa PDO/SQL.
PDO (PHP Data Objects) é uma API para acesso a bancos com suporte a prepared statements. O objetivo aqui é: configurar uma conexão segura, executar queries com parâmetros, mapear resultados para estruturas da aplicação e lidar com erros sem vazar detalhes sensíveis.
Configuração de acesso ao banco com PDO (segura e previsível)
1) Criando uma fábrica de conexão
Centralize a criação do PDO em uma fábrica. Isso evita conexões espalhadas e facilita testes (substituir por outro PDO ou por um stub).
final class PdoFactory
{
public static function create(array $config): PDO
{
$dsn = sprintf(
'%s:host=%s;port=%d;dbname=%s;charset=%s',
$config['driver'],
$config['host'],
$config['port'],
$config['dbname'],
$config['charset'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$config['user'],
$config['pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
return $pdo;
}
}Por que essas opções importam?
ERRMODE_EXCEPTION: força tratamento consistente via exceções, em vez de checar retornos.DEFAULT_FETCH_MODE: padroniza o formato dos resultados.EMULATE_PREPARES=false: usa prepared statements reais do driver quando possível (melhor segurança e tipagem).
2) Passo a passo: usando no container
Registre o PDO no container (ou crie-o em um bootstrap). A ideia é que repositórios recebam PDO por injeção.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
$container->set(PDO::class, function () use ($settings) {
return PdoFactory::create($settings['db']);
});Prepared statements: parâmetros, tipos e prevenção de SQL injection
SQL injection acontece quando dados de entrada são concatenados diretamente na query. A regra prática é: valores sempre via parâmetros. Para partes estruturais (colunas/ordenação), use whitelist.
1) Inserção com parâmetros
$sql = 'INSERT INTO users (name, email) VALUES (:name, :email)';
$stmt = $pdo->prepare($sql);
$stmt->execute([
'name' => $name,
'email' => $email,
]);
$userId = (int) $pdo->lastInsertId();2) Consulta com bindValue e tipagem
$sql = 'SELECT * FROM users WHERE id = :id';
$stmt = $pdo->prepare($sql);
$stmt->bindValue('id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch();3) IN (...) com lista dinâmica (sem concatenar valores)
Para listas, gere placeholders e passe os valores no execute.
$ids = [10, 20, 30];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT * FROM users WHERE id IN ($placeholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($ids);
$rows = $stmt->fetchAll();Mapeamento de resultados: de array para objetos/DTOs
Evite espalhar arrays associativos por toda a aplicação. Uma abordagem simples é mapear para um DTO (ou entidade) no repositório.
final class UserDTO
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $createdAt,
) {}
public static function fromRow(array $row): self
{
return new self(
(int) $row['id'],
(string) $row['name'],
(string) $row['email'],
(string) $row['created_at'],
);
}
}No repositório:
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ? UserDTO::fromRow($row) : null;Repositórios: isolando SQL do restante da aplicação
1) Definindo um contrato (interface)
O contrato descreve o que a persistência oferece, sem expor SQL.
interface UserRepository
{
public function getById(int $id): ?UserDTO;
public function search(UserSearchCriteria $criteria): PaginatedResult;
public function create(NewUser $data): int;
}2) Critérios de busca e paginação como objetos
Para filtros/ordenação/paginação, prefira um objeto de critérios. Isso evita métodos com muitos parâmetros e facilita validações/whitelists.
final class UserSearchCriteria
{
public function __construct(
public ?string $q = null,
public ?string $status = null,
public string $sort = 'created_at',
public string $direction = 'desc',
public int $page = 1,
public int $perPage = 20,
) {}
public function offset(): int
{
return max(0, ($this->page - 1) * $this->perPage);
}
}final class PaginatedResult
{
/** @param array<object> $items */
public function __construct(
public array $items,
public int $page,
public int $perPage,
public int $total,
) {}
}3) Implementação com PDO (SQL encapsulado)
Observe como filtros, paginação e ordenação são tratados com segurança.
final class PdoUserRepository implements UserRepository
{
public function __construct(private PDO $pdo) {}
public function getById(int $id): ?UserDTO
{
$stmt = $this->pdo->prepare('SELECT id, name, email, created_at FROM users WHERE id = :id');
$stmt->bindValue('id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch();
return $row ? UserDTO::fromRow($row) : null;
}
public function create(NewUser $data): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (name, email, status, created_at) VALUES (:name, :email, :status, NOW())'
);
$stmt->execute([
'name' => $data->name,
'email' => $data->email,
'status' => $data->status,
]);
return (int) $this->pdo->lastInsertId();
}
public function search(UserSearchCriteria $c): PaginatedResult
{
$where = [];
$params = [];
if ($c->q !== null && $c->q !== '') {
$where[] = '(name LIKE :q OR email LIKE :q)';
$params['q'] = '%' . $c->q . '%';
}
if ($c->status !== null && $c->status !== '') {
$where[] = 'status = :status';
$params['status'] = $c->status;
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
// Whitelist para ORDER BY (não pode ser parâmetro do PDO)
$allowedSort = ['created_at', 'name', 'email', 'status'];
$sort = in_array($c->sort, $allowedSort, true) ? $c->sort : 'created_at';
$dir = strtolower($c->direction) === 'asc' ? 'ASC' : 'DESC';
// Total
$countSql = "SELECT COUNT(*) AS total FROM users $whereSql";
$countStmt = $this->pdo->prepare($countSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetch()['total'];
// Página
$listSql = "SELECT id, name, email, created_at FROM users $whereSql ORDER BY $sort $dir LIMIT :limit OFFSET :offset";
$listStmt = $this->pdo->prepare($listSql);
foreach ($params as $k => $v) {
$listStmt->bindValue($k, $v, PDO::PARAM_STR);
}
$listStmt->bindValue('limit', $c->perPage, PDO::PARAM_INT);
$listStmt->bindValue('offset', $c->offset(), PDO::PARAM_INT);
$listStmt->execute();
$items = array_map(fn(array $row) => UserDTO::fromRow($row), $listStmt->fetchAll());
return new PaginatedResult($items, $c->page, $c->perPage, $total);
}
}Pontos-chave de segurança:
- Filtros usam parâmetros (
:q,:status). ORDER BYnão aceita bind de coluna; use whitelist.LIMIT/OFFSETcombindValueePDO::PARAM_INT.
Tratamento de erros do banco: sem vazar detalhes
PDO com ERRMODE_EXCEPTION lança PDOException. A aplicação deve converter isso para uma exceção de infraestrutura mais neutra, preservando o erro original para logs, mas sem expor SQL/credenciais.
final class PersistenceException extends RuntimeException
{
public static function fromPdo(PDOException $e): self
{
return new self('Falha ao acessar a persistência.', 0, $e);
}
}Uso no repositório:
try {
$stmt->execute($params);
} catch (PDOException $e) {
throw PersistenceException::fromPdo($e);
}Se você já possui uma camada de padronização de erros HTTP, ela deve mapear PersistenceException para uma resposta genérica (ex.: 500) e registrar o getPrevious() em log.
Transações: consistência ao gravar múltiplas tabelas
Transações garantem atomicidade: ou tudo é persistido, ou nada é. Use quando uma operação envolve múltiplas escritas dependentes (ex.: criar pedido e itens).
1) Passo a passo com begin/commit/rollBack
$pdo->beginTransaction();
try {
// 1) cria pedido
$stmt = $pdo->prepare('INSERT INTO orders (user_id, total) VALUES (:user_id, :total)');
$stmt->execute(['user_id' => $userId, 'total' => $total]);
$orderId = (int) $pdo->lastInsertId();
// 2) cria itens
$itemStmt = $pdo->prepare(
'INSERT INTO order_items (order_id, product_id, qty, price) VALUES (:order_id, :product_id, :qty, :price)'
);
foreach ($items as $item) {
$itemStmt->execute([
'order_id' => $orderId,
'product_id' => $item->productId,
'qty' => $item->qty,
'price' => $item->price,
]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}2) Encapsulando transações em um helper
Para evitar repetição, crie um executor transacional.
final class TransactionManager
{
public function __construct(private PDO $pdo) {}
/** @template T */
public function run(callable $fn)
{
$this->pdo->beginTransaction();
try {
$result = $fn($this->pdo);
$this->pdo->commit();
return $result;
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
}Uso:
$orderId = $tx->run(function (PDO $pdo) use ($userId, $items) {
// ... mesmas operações ...
return $orderId;
});Isolando queries: organização por arquivos e objetos
Mesmo dentro do repositório, queries grandes podem ficar difíceis de manter. Duas estratégias simples:
1) Arquivo de SQL por operação
Crie uma pasta resources/sql e carregue o SQL por arquivo. Isso facilita revisão e evita strings enormes no PHP.
final class Sql
{
public static function load(string $name): string
{
$path = __DIR__ . '/../../resources/sql/' . $name . '.sql';
return file_get_contents($path);
}
}$stmt = $this->pdo->prepare(Sql::load('users/search'));2) Query Objects (construtores de SQL controlados)
Para buscas com muitos filtros, um objeto dedicado pode montar WHERE e parâmetros, mantendo o repositório mais limpo.
final class UserSearchQuery
{
public function __construct(private UserSearchCriteria $c) {}
/** @return array{sql:string, params:array} */
public function build(): array
{
$where = [];
$params = [];
if ($this->c->q) {
$where[] = '(name LIKE :q OR email LIKE :q)';
$params['q'] = '%' . $this->c->q . '%';
}
if ($this->c->status) {
$where[] = 'status = :status';
$params['status'] = $this->c->status;
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$allowedSort = ['created_at', 'name', 'email', 'status'];
$sort = in_array($this->c->sort, $allowedSort, true) ? $this->c->sort : 'created_at';
$dir = strtolower($this->c->direction) === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT id, name, email, created_at FROM users $whereSql ORDER BY $sort $dir LIMIT :limit OFFSET :offset";
return ['sql' => $sql, 'params' => $params];
}
}Paginação, filtros e ordenação: checklist de boas práticas
| Requisito | Prática recomendada | Evite |
|---|---|---|
| Paginação | LIMIT e OFFSET com PARAM_INT; retornar total | Paginar em memória após buscar tudo |
| Filtros | Parâmetros nomeados; montar WHERE incremental | Concatenar valores no SQL |
| Ordenação | Whitelist de colunas e direção (ASC/DESC) | Passar sort do usuário direto no SQL |
| Busca textual | LIKE com % no parâmetro; considerar índices apropriados | Interpolar %$q% no SQL |
| Erros | Converter PDOException para exceção de persistência; logar internamente | Expor mensagem do driver ao cliente |
Migrations e seeders simples (sem depender do framework)
Uma forma direta de versionar o schema é manter scripts SQL numerados e aplicar em ordem. O controle de quais migrations já rodaram fica em uma tabela migrations.
1) Estrutura de pastas sugerida
database/migrations/(ex.:2026012501_create_users.sql)database/seeders/(ex.:dev_seed.sql)
2) Tabela de controle
CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL
);3) Runner de migrations (CLI em PHP)
Crie um script executável (ex.: bin/migrate.php) que conecta via PDO e aplica arquivos ainda não executados.
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
$pdo = PdoFactory::create(require __DIR__ . '/../config/db.php');
$pdo->exec("CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL
)");
$dir = __DIR__ . '/../database/migrations';
$files = glob($dir . '/*.sql');
sort($files);
$applied = $pdo->query('SELECT filename FROM migrations')->fetchAll(PDO::FETCH_COLUMN);
$applied = array_flip($applied);
foreach ($files as $file) {
$filename = basename($file);
if (isset($applied[$filename])) {
continue;
}
$sql = file_get_contents($file);
$pdo->beginTransaction();
try {
$pdo->exec($sql);
$stmt = $pdo->prepare('INSERT INTO migrations (filename, applied_at) VALUES (:f, NOW())');
$stmt->execute(['f' => $filename]);
$pdo->commit();
echo "Applied: $filename\n";
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "Failed: $filename\n");
throw $e;
}
}4) Exemplo de migration SQL
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR(180) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL
);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_created_at ON users(created_at);5) Seeders simples
Seeders podem ser SQL puro para ambientes de desenvolvimento/teste. Mantenha-os idempotentes quando possível (ou limpe as tabelas antes).
INSERT INTO users (name, email, status, created_at) VALUES
('Admin', 'admin@example.com', 'active', NOW()),
('User One', 'user1@example.com', 'active', NOW());Runner de seed (ex.: bin/seed.php):
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
$pdo = PdoFactory::create(require __DIR__ . '/../config/db.php');
$seedFile = __DIR__ . '/../database/seeders/dev_seed.sql';
$sql = file_get_contents($seedFile);
$pdo->beginTransaction();
try {
$pdo->exec($sql);
$pdo->commit();
echo "Seed applied\n";
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}Integração com a camada de aplicação: mantendo handlers enxutos
A camada HTTP deve apenas traduzir entrada/saída. Quem decide “o que fazer” é um serviço/caso de uso que depende do repositório (interface). Exemplo de serviço que usa transação e repositórios:
final class CreateOrderService
{
public function __construct(
private TransactionManager $tx,
private OrderRepository $orders,
private OrderItemRepository $items,
) {}
public function execute(CreateOrderCommand $cmd): int
{
return $this->tx->run(function () use ($cmd) {
$orderId = $this->orders->create($cmd->userId, $cmd->total);
foreach ($cmd->items as $item) {
$this->items->add($orderId, $item);
}
return $orderId;
});
}
}Assim, SQL e detalhes de persistência ficam contidos nos repositórios, e a regra de negócio fica testável e independente do banco.