Curso Completo de PHP — Parte 07

Ban­co de Dados com PDO e MySQL: Queries Seguras, Transações e Repos­i­to­ry Pat­tern Com­ple­to

Dom­i­nar PDO não é ape­nas uma boa práti­ca — é a lin­ha que sep­a­ra apli­cações profis­sion­ais de sis­temas vul­neráveis e frágeis.

1. Por que PDO e não MySQLi? {#por-que-pdo}

Antes de escr­ev­er uma lin­ha de códi­go, pre­cisamos ter clareza sobre uma decisão arquite­tur­al fun­da­men­tal: por que PDO?

A exten­são mysql_* foi depre­ci­a­da no PHP 5.5 e removi­da no PHP 7.0. Isso deixou dois suces­sores: MySQLi e PDO (PHP Data Objects). Muitos desen­volve­dores chegam ao PDO por exclusão, sem enten­der por que ele é gen­uina­mente supe­ri­or na maio­r­ia dos cenários.

Abstração Real de Banco de Dados

O argu­men­to mais forte do PDO é a abstração de dri­ver. Com PDO, você pode tro­car de MySQL para Post­greSQL, SQLite ou SQL Serv­er alteran­do basi­ca­mente a string de conexão (DSN). Seu códi­go de aces­so a dados per­manece intac­to.

// 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 é ape­nas con­veniên­cia teóri­ca. Na práti­ca, sig­nifi­ca que você pode usar SQLite nos testes autom­a­ti­za­dos e MySQL em pro­dução, com o mes­mo repositório de dados. Isso é uma van­tagem arquite­tur­al conc­re­ta.

Named Parameters vs. Positional

PDO supor­ta dois esti­los de bind­ing: posi­cional (?) e nom­i­nal (:nome). O MySQLi supor­ta ape­nas posi­cional. Para queries com­plexas com muitos parâmet­ros, os named para­me­ters tor­nam o códi­go dra­mati­ca­mente mais legív­el e menos propen­so 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 pro­je­ta­do des­de o iní­cio como uma API OO coesa. MySQLi tem uma inter­face pro­ce­dur­al lega­da que coex­iste com a OO, geran­do incon­sistên­cia. Para pro­je­tos mod­er­nos com padrões como Repos­i­to­ry e Active Record, PDO se encaixa nat­u­ral­mente.


2. Configurando a Conexão PDO do Jeito Certo {#configurando-conexao}

A maio­r­ia dos tuto­ri­ais mostra uma conexão PDO bási­ca. Vamos além e con­fig­u­raremos uma conexão pro­duc­tion-ready, com todas as opções rel­e­vantes definidas explici­ta­mente.

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?

Nun­ca use charset=utf8 no MySQL. O charset utf8 do MySQL é, na ver­dade, um utf8mb3 — supor­ta ape­nas 3 bytes por car­ac­tere, o que exclui emo­jis e muitos car­ac­teres asiáti­cos raros. O utf8mb4 é o UTF‑8 real (4 bytes), e você deve usá-lo em todos os pro­je­tos novos.

Além de definir no DSN, con­fig­ure tam­bém no servi­dor MySQL:

SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;

Por Que ATTR_EMULATE_PREPARES = false?

Com emu­lação ati­va­da (padrão), o PDO mon­ta a query SQL man­ual­mente no lado do PHP — o MySQL recebe uma string já inter­po­la­da. Com emu­lação desati­va­da, o dri­ver envia o tem­plate e os val­ores sep­a­rada­mente para o MySQL, que os proces­sa de for­ma iso­la­da. Isso ofer­ece:

  1. Pro­teção real con­tra SQL injec­tion (não ape­nas escape de strings)
  2. Tipos nativos preser­va­dos — o MySQL retor­na INT como inteiro PHP, não como string
  3. Exe­cução mais efi­ciente para queries exe­cu­tadas múlti­plas vezes

A úni­ca desvan­tagem é que você não pode usar o mes­mo named para­me­ter duas vezes na mes­ma query com alguns dri­vers. Para MySQL, isso rara­mente é um prob­le­ma práti­co.


3. Prepared Statements: A Base de Tudo {#prepared-statements}

Pre­pared state­ments são o coração do PDO seguro. A ideia é sim­ples: sep­a­rar estru­tu­ra de dados.

Em vez de con­stru­ir a query com os dados do usuário embu­ti­dos, você envia um tem­plate com place­hold­ers e depois fornece os val­ores sep­a­rada­mente. O ban­co de dados nun­ca con­funde 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áx­i­ma segu­rança e per­for­mance, você pode faz­er bind­ing explíc­i­to especi­f­i­can­do 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();

Difer­ença cru­cial 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ê pre­cisa reusar o state­ment com var­iá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 injec­tion con­tin­ua sendo a vul­ner­a­bil­i­dade mais explo­ra­da na web, apare­cen­do con­sis­ten­te­mente no topo do OWASP Top 10. Enten­der mecani­ca­mente por que pre­pared state­ments previnem isso — não ape­nas que previnem — é essen­cial.

A Anatomia de um Ataque

Con­sidere este códi­go vul­neráv­el:

// 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 entra­da mais destru­ti­va:

1; DROP TABLE usuarios; --

Com con­cate­nação dire­ta, esta string vira instrução SQL exe­cutáv­el.

Por Que Prepared Statements São Imunes

Com ATTR_EMULATE_PREPARES = false, o fluxo é:

  1. Fase 1 — Parse: MySQL recebe SELECT * FROM usuarios WHERE id = ? e cria um plano de exe­cução. Neste pon­to, a estru­tu­ra SQL está fix­a­da.
  2. Fase 2 — Bind: O val­or do usuário (1 OR 1=1 --) é envi­a­do como dado puro, vin­cu­la­do ao place­hold­er ?.
  3. Fase 3 — Exe­cute: MySQL exe­cu­ta com o val­or lit­eral­mente como o con­teú­do do cam­po id. A string 1 OR 1=1 -- é trata­da como um val­or de string, não como códi­go SQL.

O MySQL lit­eral­mente não tem como con­fundir dado com instrução porque eles chegam em canais sep­a­ra­dos.

Casos Onde Você Ainda Precisa de Cuidado

Pre­pared state­ments não resolvem tudo. Há casos onde você manip­u­la partes da query que não podem ser param­e­trizadas:

// 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 pre­pared state­ment place­hold­er (nomes de tabelas, col­u­nas, cláusu­las ORDER BY, LIMIT dinâmi­co), use whitelist de val­ores per­mi­ti­dos, nun­ca black­list ou escape.


5. Tipos de Fetch e Manipulação de Resultados {#tipos-de-fetch}

PDO ofer­ece múlti­p­los mod­os de fetch, cada um ade­qua­do a um cenário difer­ente.

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 arquite­turas ori­en­tadas a obje­tos:

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']

Extrema­mente útil para pop­u­lar <select> em for­mulá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 mecan­is­mo que garante a atom­i­ci­dade das oper­ações no ban­co de dados. Ou todas as oper­ações de um grupo são con­fir­madas (com­mit), ou nen­hu­ma é (roll­back). Sem transações, seu sis­tema pode ficar em esta­do incon­sis­tente após uma fal­ha par­cial.

O Problema Sem Transações

Imag­ine proces­sar um pedi­do 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 servi­dor cair após o pas­so 2, o estoque foi deb­ita­do mas o pedi­do não existe. O pro­du­to sum­iu do estoque sem ger­ar recei­ta. 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 supor­ta transações ver­dadeira­mente anin­hadas, mas você pode sim­u­lar com save­points 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 é espe­cial­mente impor­tante em códi­go que pode ser chama­do den­tro ou fora de uma transação exis­tente.


7. Tratamento de Erros e Exceções {#tratamento-erros}

Hierarquia de Exceções PDO

\Exception
└── \RuntimeException
    └── \PDOException

PDOException expõe:

  • getMessage() — men­sagem de erro
  • getCode() — SQLSTATE (string de 5 car­ac­teres, ex: '23000' para vio­lação de con­straint)
  • 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 cama­da de apli­cação lida com DuplicateEntryException de for­ma semân­ti­ca, sem con­hecer detal­h­es do ban­co de dados.


8. Repository Pattern: Teoria e Prática Completa {#repository-pattern}

O Repos­i­to­ry Pat­tern é um dos padrões de design mais impor­tantes para apli­cações com aces­so a ban­co de dados. Ele cria uma cama­da de abstração entre a lóg­i­ca de negó­cio e a infraestru­tu­ra de per­sistên­cia.

Por Que Repository Pattern?

Sem Repos­i­to­ry Pat­tern, sua lóg­i­ca de negó­cio fre­quente­mente 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
    }
}

Prob­le­mas:

  • SQL espal­ha­do por toda a apli­cação
  • Impos­sív­el tes­tar sem ban­co de dados real
  • Tro­car de ban­co de dados requer ree­scr­ev­er lóg­i­ca de negó­cio
  • Vio­lação do princí­pio da respon­s­abil­i­dade úni­ca

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 inter­face não men­ciona PDO, SQL ou ban­co de dados em lugar algum. Ela fala ape­nas em ter­mos 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 conec­tar tudo num caso de uso con­cre­to: o ProductService que usa o repositório através da inter­face.

<?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 qual­quer ban­co de dados. Ele tra­bal­ha ape­nas com ProductRepositoryInterface. Isso é o desacopla­men­to 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 reg­istros um a um é lento. A com­bi­nação de pre­pared state­ment + transação é dra­mati­ca­mente mais ráp­i­da:

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 prob­le­ma clás­si­co com PDO: como param­e­trizar WHERE id IN (1, 2, 3) quan­do a lista é dinâmi­ca?

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 bene­fí­cios do Repos­i­to­ry Pat­tern é a testa­bil­i­dade. Você pode cri­ar imple­men­tações in-mem­o­ry para testes unitários rápi­dos, sem ban­co 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());
    }
}

Ess­es testes rodam em milis­se­gun­dos, sem nen­hu­ma conexão com ban­co de dados. O Repos­i­to­ry Pat­tern tornou isso pos­sív­el.


12. Checklist de Segurança Final {#checklist}

Configuração de Conexão

  • [ ] ATTR_ERRMODE => ERRMODE_EXCEPTION ati­va­do
  • [ ] ATTR_EMULATE_PREPARES => false para MySQL
  • [ ] Charset utf8mb4 no DSN e no servi­dor
  • [ ] Cre­den­ci­ais em var­iáveis de ambi­ente, nun­ca no códi­go-fonte
  • [ ] SQL mode estri­to ati­va­do na sessão
  • [ ] Erros de conexão não expõem cre­den­ci­ais ao usuário

Queries e Parâmetros

  • [ ] Zero con­cate­nação de var­iáveis exter­nas em SQL
  • [ ] Todos os val­ores de usuário via pre­pared state­ments
  • [ ] Nomes de tabelas/colunas dinâmi­cos usam whitelist
  • [ ] Cláusu­las ORDER BY dinâmi­cas san­i­ti­zadas
  • [ ] LIMIT e OFFSET via PDO::PARAM_INT

Transações

  • [ ] Todo grupo de oper­ações rela­cionadas envolto em transação
  • [ ] rollBack() garan­ti­do em blo­co catch
  • [ ] rowCount() ver­i­fi­ca­do após UPDATE/DELETE críti­cos
  • [ ] lastInsertId() val­i­da­do após INSERT

Arquitetura

  • [ ] Repos­i­to­ry Pat­tern imple­men­ta­do com inter­face
  • [ ] Ser­vice lay­er não tem dependên­cias de infraestru­tu­ra de ban­co
  • [ ] Exceções de ban­co con­ver­tidas para exceções de domínio
  • [ ] Imple­men­tação in-mem­o­ry disponív­el para testes
  • [ ] Nen­hum SQL no códi­go de negó­cio

Erros e Logging

  • [ ] Men­sagens de erro para usuário não expõem stack trace
  • [ ] Erros de ban­co loga­dos com con­tex­to sufi­ciente para debug
  • [ ] SQLSTATE e dri­ver code cap­tura­dos e cat­e­go­riza­dos
  • [ ] Dead­locks trata­dos com retry automáti­co onde aplicáv­el

Conclusão

PDO não é ape­nas uma fer­ra­men­ta — é uma filosofia de aces­so a dados. Cada escol­ha que explo­ramos neste arti­go tem uma razão arquite­tur­al clara:

Pre­pared state­ments elim­i­nam SQL injec­tion na raiz, não como patch, mas como con­se­quên­cia nat­ur­al de sep­a­rar estru­tu­ra de dados. ATTR_EMULATE_PREPARES = false garante que essa sep­a­ração acon­teça de ver­dade, no nív­el do pro­to­co­lo de ban­co de dados. Transações trans­for­mam gru­pos de oper­ações frágeis em unidades atômi­cas con­fiáveis. E o Repos­i­to­ry Pat­tern garante que nen­hu­ma dessas decisões de infraestru­tu­ra con­t­a­mine a lóg­i­ca de negó­cio — que per­manece limpa, testáv­el e inde­pen­dente.

A dis­tân­cia entre um sis­tema que “fun­ciona” e um que é seguro, testáv­el e main­taináv­el é exata­mente esta pil­ha de decisões. Ago­ra você tem todas elas.


Arti­go pro­duzi­do com pro­fun­di­dade téc­ni­ca para desen­volve­dores PHP que bus­cam con­stru­ir sis­temas robus­tos e seguros em pro­dução.

Cur­so Com­ple­to de PHP — Parte 06

Posts Similares

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *