No artigo anterior construímos um repositório com SQL escrito à mão. Isso funciona bem para queries simples, mas à medida que filtros dinâmicos aparecem — "buscar produtos por categoria, com faixa de preço opcional, com ou sem estoque" — o código vira uma série de if concatenando strings SQL, propenso a bugs e difícil de testar.
Query Builders resolvem isso: são objetos que constroem SQL programaticamente de forma fluente, segura e testável. E Migrations resolvem outro problema igualmente importante: como versionar o schema do banco de dados junto com o código, garantindo que qualquer desenvolvedor do time possa recriar o banco idêntico com um único comando.
Neste artigo construímos um Query Builder funcional do zero, entendemos como migrations funcionam internamente, e vemos os padrões que frameworks como Laravel e Phinx implementam.
Query Builder — construindo SQL dinamicamente
Um Query Builder deve produzir sempre SQL válido com parâmetros separados — nunca interpolar valores diretamente na query. A interface fluente (cada método retorna $this) permite encadear chamadas na ordem que fizer sentido para o código.
<?php
declare(strict_types=1);
class QueryBuilder
{
private string $tabela = '';
private array $selects = ['*'];
private array $joins = [];
private array $wheres = [];
private array $params = [];
private array $orderBys = [];
private ?int $limite = null;
private ?int $offset = null;
private array $groupBys = [];
private ?string $having = null;
// ── DSL fluente ──────────────────────────────────────────────────
public function from(string $tabela): static
{
$this->tabela = $tabela;
return $this;
}
public function select(string ...$colunas): static
{
$this->selects = $colunas;
return $this;
}
public function join(string $tabela, string $condicao, string $tipo = 'INNER'): static
{
$this->joins[] = strtoupper($tipo) . " JOIN {$tabela} ON {$condicao}";
return $this;
}
public function leftJoin(string $tabela, string $condicao): static
{
return $this->join($tabela, $condicao, 'LEFT');
}
public function where(string $condicao, mixed ...$valores): static
{
$this->wheres[] = $condicao;
array_push($this->params, ...$valores);
return $this;
}
// Adiciona WHERE apenas se o valor não for null — padrão "filtro opcional"
public function whereIf(?string $valor, string $condicao, mixed ...$params): static
{
if ($valor !== null && $valor !== '') {
return $this->where($condicao, ...$params);
}
return $this;
}
public function whereIn(string $coluna, array $valores): static
{
if (empty($valores)) return $this;
$placeholders = implode(', ', array_fill(0, count($valores), '?'));
$this->wheres[] = "{$coluna} IN ({$placeholders})";
array_push($this->params, ...$valores);
return $this;
}
public function orderBy(string $coluna, string $direcao = 'ASC'): static
{
$direcao = strtoupper($direcao) === 'DESC' ? 'DESC' : 'ASC';
$this->orderBys[] = "{$coluna} {$direcao}";
return $this;
}
public function groupBy(string ...$colunas): static
{
array_push($this->groupBys, ...$colunas);
return $this;
}
public function having(string $condicao): static
{
$this->having = $condicao;
return $this;
}
public function limit(int $limite): static
{
$this->limite = $limite;
return $this;
}
public function offset(int $offset): static
{
$this->offset = $offset;
return $this;
}
// ── Compilação ────────────────────────────────────────────────────
public function build(): array // retorna [$sql, $params]
{
if (empty($this->tabela)) {
throw new \LogicException("Tabela não definida — chame from() antes de build().");
}
$partes = [];
// SELECT
$partes[] = 'SELECT ' . implode(', ', $this->selects);
// FROM
$partes[] = 'FROM ' . $this->tabela;
// JOINs
if (!empty($this->joins)) {
$partes[] = implode("\n", $this->joins);
}
// WHERE
if (!empty($this->wheres)) {
$partes[] = 'WHERE ' . implode(' AND ', $this->wheres);
}
// GROUP BY
if (!empty($this->groupBys)) {
$partes[] = 'GROUP BY ' . implode(', ', $this->groupBys);
}
// HAVING
if ($this->having !== null) {
$partes[] = 'HAVING ' . $this->having;
}
// ORDER BY
if (!empty($this->orderBys)) {
$partes[] = 'ORDER BY ' . implode(', ', $this->orderBys);
}
// LIMIT / OFFSET
if ($this->limite !== null) {
$partes[] = 'LIMIT ' . $this->limite;
}
if ($this->offset !== null) {
$partes[] = 'OFFSET ' . $this->offset;
}
return [implode("\n", $partes), $this->params];
}
public function executar(\PDO $pdo): array
{
[$sql, $params] = $this->build();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ── Métodos de conveniência para INSERT/UPDATE/DELETE ─────────────
public static function inserir(string $tabela, array $dados, \PDO $pdo): int
{
$colunas = implode(', ', array_keys($dados));
$placeholders = implode(', ', array_fill(0, count($dados), '?'));
$stmt = $pdo->prepare("INSERT INTO {$tabela} ({$colunas}) VALUES ({$placeholders})");
$stmt->execute(array_values($dados));
return (int) $pdo->lastInsertId();
}
public static function atualizar(string $tabela, array $dados, string $where, array $whereParams, \PDO $pdo): int
{
$sets = implode(', ', array_map(fn($col) => "{$col} = ?", array_keys($dados)));
$stmt = $pdo->prepare("UPDATE {$tabela} SET {$sets} WHERE {$where}");
$stmt->execute([...array_values($dados), ...$whereParams]);
return $stmt->rowCount();
}
}
Usando o Query Builder — filtros dinâmicos
O grande benefício do Query Builder aparece quando os filtros são opcionais. Sem ele, o código fica cheio de concatenações perigosas:
<?php
declare(strict_types=1);
// ── FILTROS OPCIONAIS — o padrão mais comum ───────────────────────────
function buscarProdutos(
\PDO $pdo,
?string $categoria = null,
?float $precoMin = null,
?float $precoMax = null,
?bool $apenasAtivos = true,
int $pagina = 1,
int $porPagina = 20,
): array {
$qb = (new QueryBuilder())
->from('produtos p')
->select('p.id', 'p.nome', 'p.preco', 'p.estoque', 'c.nome AS categoria')
->leftJoin('categorias c', 'c.id = p.categoria_id')
->orderBy('p.nome')
->limit($porPagina)
->offset(($pagina - 1) * $porPagina);
// Cada filtro só é adicionado se o valor for fornecido
if ($apenasAtivos) {
$qb->where('p.ativo = ?', 1);
}
if ($categoria !== null) {
$qb->where('c.slug = ?', $categoria);
}
if ($precoMin !== null) {
$qb->where('p.preco >= ?', $precoMin);
}
if ($precoMax !== null) {
$qb->where('p.preco <= ?', $precoMax);
}
return $qb->executar($pdo);
}
// Busca com todos os filtros
$resultado = buscarProdutos(
pdo: $pdo,
categoria: 'eletronicos',
precoMin: 100.0,
precoMax: 2000.0,
pagina: 2,
);
// Busca simples — sem filtros opcionais
$todos = buscarProdutos(pdo: $pdo, apenasAtivos: false);
// ── RELATÓRIO COM GROUP BY ─────────────────────────────────────────────
$vendasPorCategoria = (new QueryBuilder())
->from('pedido_itens pi')
->select(
'c.nome AS categoria',
'SUM(pi.quantidade) AS total_vendido',
'SUM(pi.quantidade * pi.preco) AS receita',
)
->join('produtos p', 'p.id = pi.produto_id')
->join('categorias c', 'c.id = p.categoria_id')
->join('pedidos pe', 'pe.id = pi.pedido_id')
->where('pe.criado_em >= ?', '2024-01-01')
->where('pe.status = ?', 'entregue')
->groupBy('c.id', 'c.nome')
->having('total_vendido > 0')
->orderBy('receita', 'DESC')
->executar($pdo);
// ── WHERE IN com lista dinâmica ────────────────────────────────────────
$ids = [1, 5, 12, 47, 99];
$produtosSelecionados = (new QueryBuilder())
->from('produtos')
->select('id', 'nome', 'preco')
->whereIn('id', $ids)
->orderBy('nome')
->executar($pdo);
Migrations — versionando o schema
Migrations são arquivos PHP que descrevem uma mudança no schema do banco de dados. Cada migration tem dois métodos: up() (aplica a mudança) e down() (desfaz). Uma tabela de controle no banco registra quais migrations já foram executadas, garantindo que cada migration rode apenas uma vez e na ordem correta.
<?php
declare(strict_types=1);
// ── ESTRUTURA BASE ────────────────────────────────────────────────────
abstract class Migration
{
public function __construct(protected \PDO $pdo) {}
abstract public function up(): void;
abstract public function down(): void;
protected function executar(string $sql): void
{
$this->pdo->exec($sql);
}
}
// ── MIGRATION 001 — criação da tabela usuarios ─────────────────────────
class Migration001CriarTabelaUsuarios extends Migration
{
public function up(): void
{
$this->executar("
CREATE TABLE IF NOT EXISTS usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
ativo TINYINT(1) NOT NULL DEFAULT 1,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_ativo (ativo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
public function down(): void
{
$this->executar("DROP TABLE IF EXISTS usuarios");
}
}
// ── MIGRATION 002 — criação de produtos e categorias ──────────────────
class Migration002CriarProdutos extends Migration
{
public function up(): void
{
$this->executar("
CREATE TABLE IF NOT EXISTS categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$this->executar("
CREATE TABLE IF NOT EXISTS produtos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categoria_id INT UNSIGNED NOT NULL,
nome VARCHAR(200) NOT NULL,
descricao TEXT,
preco DECIMAL(10,2) NOT NULL,
estoque INT UNSIGNED NOT NULL DEFAULT 0,
ativo TINYINT(1) NOT NULL DEFAULT 1,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE RESTRICT,
INDEX idx_categoria (categoria_id),
INDEX idx_ativo_preco (ativo, preco)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
public function down(): void
{
// Ordem inversa — respeita foreign keys
$this->executar("DROP TABLE IF EXISTS produtos");
$this->executar("DROP TABLE IF EXISTS categorias");
}
}
// ── MIGRATION 003 — adicionar coluna a tabela existente ──────────────
class Migration003AdicionarPerfilUsuario extends Migration
{
public function up(): void
{
$this->executar("
ALTER TABLE usuarios
ADD COLUMN perfil ENUM('usuario','admin','moderador') NOT NULL DEFAULT 'usuario' AFTER email,
ADD COLUMN ultimo_login DATETIME NULL AFTER perfil,
ADD INDEX idx_perfil (perfil)
");
}
public function down(): void
{
$this->executar("ALTER TABLE usuarios DROP INDEX idx_perfil");
$this->executar("ALTER TABLE usuarios DROP COLUMN ultimo_login");
$this->executar("ALTER TABLE usuarios DROP COLUMN perfil");
}
}
MigrationRunner — o executor de migrations
O runner é responsável por criar a tabela de controle, descobrir quais migrations ainda não rodaram, e executá-las em ordem — tudo dentro de transações para garantir atomicidade.
<?php
declare(strict_types=1);
class MigrationRunner
{
private const TABELA_CONTROLE = 'migrations';
public function __construct(private readonly \PDO $pdo) {}
public function inicializar(): void
{
$this->pdo->exec("
CREATE TABLE IF NOT EXISTS " . self::TABELA_CONTROLE . " (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
executado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_migration (migration)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
/** @param class-string<Migration>[] $classes */
public function migrar(array $classes): void
{
$this->inicializar();
$executadas = $this->jaExecutadas();
foreach ($classes as $classe) {
if (in_array($classe, $executadas, true)) {
echo " ⏭ Já executada: {$classe}\n";
continue;
}
$this->pdo->beginTransaction();
try {
/** @var Migration $migration */
$migration = new $classe($this->pdo);
$migration->up();
$this->registrar($classe);
$this->pdo->commit();
echo " ✅ Executada: {$classe}\n";
} catch (\Throwable $e) {
$this->pdo->rollBack();
echo " ❌ Falhou: {$classe} — " . $e->getMessage() . "\n";
throw $e;
}
}
}
/** @param class-string<Migration>[] $classes */
public function rollback(array $classes, int $passos = 1): void
{
$this->inicializar();
$executadas = $this->jaExecutadas();
$paraDesfazer = array_reverse(
array_filter($classes, fn($c) => in_array($c, $executadas, true))
);
foreach (array_slice($paraDesfazer, 0, $passos) as $classe) {
$this->pdo->beginTransaction();
try {
$migration = new $classe($this->pdo);
$migration->down();
$this->remover($classe);
$this->pdo->commit();
echo " ↩ Revertida: {$classe}\n";
} catch (\Throwable $e) {
$this->pdo->rollBack();
echo " ❌ Falhou ao reverter: {$classe} — " . $e->getMessage() . "\n";
throw $e;
}
}
}
private function jaExecutadas(): array
{
$stmt = $this->pdo->query(
"SELECT migration FROM " . self::TABELA_CONTROLE . " ORDER BY id"
);
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
private function registrar(string $classe): void
{
$this->pdo->prepare(
"INSERT INTO " . self::TABELA_CONTROLE . " (migration) VALUES (?)"
)->execute([$classe]);
}
private function remover(string $classe): void
{
$this->pdo->prepare(
"DELETE FROM " . self::TABELA_CONTROLE . " WHERE migration = ?"
)->execute([$classe]);
}
}
// ── USO ───────────────────────────────────────────────────────────────
$runner = new MigrationRunner($pdo);
// Lista de migrations na ORDEM CORRETA
$migrations = [
Migration001CriarTabelaUsuarios::class,
Migration002CriarProdutos::class,
Migration003AdicionarPerfilUsuario::class,
];
echo "Executando migrations...\n";
$runner->migrar($migrations);
// Rollback do último passo
// $runner->rollback($migrations, passos: 1);
Seeders — populando dados iniciais
Seeders são migrations especiais que inserem dados de referência ou de teste. Não têm down() porque dados de seed geralmente não são removidos por rollback — são truncados manualmente.
<?php
declare(strict_types=1);
abstract class Seeder
{
public function __construct(protected \PDO $pdo) {}
abstract public function run(): void;
}
class CategoriasSeeder extends Seeder
{
public function run(): void
{
$stmt = $this->pdo->prepare(
"INSERT IGNORE INTO categorias (nome, slug) VALUES (?, ?)"
);
$categorias = [
['Eletrônicos', 'eletronicos'],
['Informática', 'informatica'],
['Periféricos', 'perifericos'],
['Acessórios', 'acessorios'],
];
foreach ($categorias as [$nome, $slug]) {
$stmt->execute([$nome, $slug]);
echo " 📦 Categoria: {$nome}\n";
}
}
}
class ProdutosSeeder extends Seeder
{
public function run(): void
{
// Busca IDs das categorias já criadas
$categorias = $this->pdo->query(
"SELECT id, slug FROM categorias"
)->fetchAll(\PDO::FETCH_KEY_PAIR);
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO produtos (categoria_id, nome, preco, estoque)
VALUES (:categoria_id, :nome, :preco, :estoque)
");
$produtos = [
['cat' => 'perifericos', 'nome' => 'Teclado Mecânico', 'preco' => 350.00, 'estoque' => 50],
['cat' => 'perifericos', 'nome' => 'Mouse Gamer', 'preco' => 180.00, 'estoque' => 80],
['cat' => 'informatica', 'nome' => 'SSD 1TB NVMe', 'preco' => 450.00, 'estoque' => 30],
['cat' => 'eletronicos', 'nome' => 'Monitor 27" 144Hz', 'preco' => 1800.00,'estoque' => 15],
];
foreach ($produtos as $p) {
$catId = $categorias[$p['cat']] ?? null;
if ($catId === null) continue;
$stmt->execute([
'categoria_id' => $catId,
'nome' => $p['nome'],
'preco' => $p['preco'],
'estoque' => $p['estoque'],
]);
echo " 🛍 Produto: {$p['nome']}\n";
}
}
}
// Executar seeders
echo "Populando banco de dados...\n";
(new CategoriasSeeder($pdo))->run();
(new ProdutosSeeder($pdo))->run();
echo "Concluído.\n";
Resumo do artigo
| Conceito | O que aprendemos |
|---|---|
| Query Builder | Constrói SQL programaticamente — seguro, fluente, testável |
whereIf() |
Adiciona cláusula WHERE apenas se o valor for fornecido |
whereIn() |
Gera placeholders dinamicamente para listas de valores |
build() |
Retorna [$sql, $params] — SQL e parâmetros sempre separados |
| Migration | Arquivo PHP com up() e down() — versiona mudanças de schema |
| Tabela de controle | Registra quais migrations já rodaram — garante idempotência |
| MigrationRunner | Executa migrations pendentes em ordem, dentro de transações |
| Rollback | Reverte as N últimas migrations executando down() |
| Seeder | Popula dados iniciais ou de teste — separado das migrations |
Exercício da semana
-
Estenda o
QueryBuildercom suporte a subqueries: métodowhereSubquery(string $coluna, string $operador, QueryBuilder $sub)que geraWHERE coluna OPERADOR (SELECT ...). Teste comWHERE id IN (SELECT produto_id FROM pedido_itens WHERE quantidade > 5). -
Adicione paginação ao
QueryBuilder: métodopaginar(int $pagina, int $porPagina): statice métodocontarTotal(\PDO $pdo): intque executaSELECT COUNT(*) FROM ... WHERE ...sem o LIMIT/OFFSET. -
Crie as migrations para um sistema de pedidos completo:
Migration001Usuarios,Migration002Categorias,Migration003Produtos,Migration004Pedidos,Migration005PedidoItens. Cada uma comup()edown()corretos, respeitando a ordem de foreign keys. -
Implemente um
SeederRunnerque recebe uma lista de seeders e os executa em ordem, exibindo tempo de execução de cada um usandohrtime(true). -
Desafio: crie um comando de linha de comando
php migrate.php [migrate|rollback|status]. Ostatuslista todas as migrations com indicação visual de executadas (✅) e pendentes (⏳). Use$argvpara ler o comando e$argcpara validar os argumentos.
Referências
- PHP Manual — PDO::prepare: https://www.php.net/manual/pt_BR/pdo.prepare.php
- Phinx — PHP Migrations library: https://book.cakephp.org/phinx/0/en/index.html
- Laravel — Database Migrations: https://laravel.com/docs/migrations
- Laravel — Query Builder: https://laravel.com/docs/queries
- Doctrine DBAL — Schema Manager: https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/schema-manager.html