Persistência e banco de dados em Slim Framework: PDO, repositórios e transações

Capítulo 10

Tempo estimado de leitura: 12 minutos

+ Exercício

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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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 BY não aceita bind de coluna; use whitelist.
  • LIMIT/OFFSET com bindValue e PDO::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

RequisitoPrática recomendadaEvite
PaginaçãoLIMIT e OFFSET com PARAM_INT; retornar totalPaginar em memória após buscar tudo
FiltrosParâmetros nomeados; montar WHERE incrementalConcatenar valores no SQL
OrdenaçãoWhitelist de colunas e direção (ASC/DESC)Passar sort do usuário direto no SQL
Busca textualLIKE com % no parâmetro; considerar índices apropriadosInterpolar %$q% no SQL
ErrosConverter PDOException para exceção de persistência; logar internamenteExpor 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.

Agora responda o exercício sobre o conteúdo:

Ao implementar ordenação dinâmica em uma busca com PDO, qual abordagem mantém a query segura e consistente com boas práticas?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Em ORDER BY não se deve parametrizar coluna/direção via PDO. O seguro é aplicar whitelist para a coluna e limitar a direção a ASC/DESC, mantendo filtros como parâmetros e tipando LIMIT/OFFSET com PDO::PARAM_INT.

Próximo capitúlo

Autenticação e autorização no Back-end com Slim Framework: JWT e políticas

Arrow Right Icon
Capa do Ebook gratuito Back-end com Slim Framework (PHP): Rotas, Middlewares e Arquitetura Limpa
63%

Back-end com Slim Framework (PHP): Rotas, Middlewares e Arquitetura Limpa

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.