Design Patterns: Singleton, Factory e Builder

Design Patterns: Singleton, Factory e Builder

Três padrões de criação e o que cada um custa: o Singleton, que garante instância única ao preço de uma dependência escondida; o Factory, que decide qual classe instanciar; e o Builder, que monta objetos complexos por etapas — e que o PHP 8 tornou dispensável em boa parte dos casos.
PHP

25 min de leitura

Design Patterns (Padrões de Projeto) são soluções codificadas para problemas que aparecem repetidamente no design de software orientado a objetos. O livro Design Patterns (GoF, 1994) catalogou 23 padrões em três categorias: criacionais (como criar objetos), estruturais (como compor objetos) e comportamentais (como objetos comunicam).

Neste artigo cobrimos os três padrões criacionais mais usados em PHP: Singleton (garante uma única instância), Factory Method (delega a criação a subclasses) e Builder (constrói objetos complexos passo a passo). Você os encontrará diretamente em Laravel, Symfony e Doctrine.

Os três padrões — visão geral

Singleton
Problema Múltiplas instâncias de um recurso compartilhado
Solução Construtor privado + instância estática
Uso real Conexão de banco, Logger, Config
Factory Method
Problema Código cliente acopla-se a classes concretas
Solução Método que cria objetos sem expor a classe
Uso real Drivers de BD, Notificações, Parsers
Builder
Problema Construtores com muitos parâmetros opcionais
Solução Objeto dedicado que monta o produto passo a passo
Uso real QueryBuilder, Email, Config, HTTP Client

Singleton — uma única instância

O Singleton garante que uma classe tenha exatamente uma instância durante toda a execução, e fornece um ponto de acesso global a ela. O caso de uso mais clássico é a conexão de banco de dados — abrir uma nova conexão a cada chamada seria caro e desnecessário. O padrão usa três elementos: construtor privado (impede new externo), propriedade estática para guardar a instância, e método estático getInstance() que cria ou retorna a instância existente.

 
src/Database/Conexao.phpPHP
<?php
declare(strict_types=1);

namespace MeuApp\Database;

use PDO;
use PDOException;

final class Conexao
{
    // A instância única fica guardada nesta propriedade estática
    private static ?self $instancia = null;
    private PDO $pdo;

    // Construtor privado — nenhum código externo pode chamar "new Conexao()"
    private function __construct(
        string $dsn,
        string $usuario,
        string $senha,
    ) {
        try {
            $this->pdo = new PDO($dsn, $usuario, $senha, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        } catch (PDOException $e) {
            throw new \RuntimeException("Falha ao conectar ao banco: " . $e->getMessage());
        }
    }

    // Método estático — único ponto de acesso à instância
    public static function getInstance(
        string $dsn     = "mysql:host=localhost;dbname=app;charset=utf8mb4",
        string $usuario = "root",
        string $senha   = "",
    ): static {
        // Cria a instância apenas na primeira chamada
        if (self::$instancia === null) {
            self::$instancia = new self($dsn, $usuario, $senha);
        }
        return self::$instancia;
    }

    // Impede clonagem — mantém a unicidade
    private function __clone(): void {}

    public function pdo(): PDO { return $this->pdo; }

    public function buscarTodos(string $tabela): array
    {
        return $this->pdo
            ->query("SELECT * FROM {$tabela}")
            ->fetchAll();
    }
}

// Uso — todas as chamadas retornam a MESMA instância
$db1 = Conexao::getInstance();
$db2 = Conexao::getInstance();

var_dump($db1 === $db2);  // bool(true) — mesma instância na memória

$produtos = Conexao::getInstance()->buscarTodos("produtos");
⚠️ Singleton — use com moderação

O Singleton é frequentemente apontado como um antipadrão porque cria estado global, dificulta testes unitários (não dá para substituir por um mock facilmente) e introduz acoplamento oculto. Em PHP moderno, a prática preferida é injeção de dependência — um container DI (como o do Laravel ou Symfony) gerencia a criação e o ciclo de vida dos objetos. Use Singleton com consciência: é aceitável para conexões de banco, loggers e configurações; evite para lógica de negócio.

Factory Method — delegar a criação

O Factory Method define um método para criar objetos, mas deixa as subclasses (ou uma classe factory dedicada) decidirem qual classe concreta instanciar. O cliente passa a depender de uma interface ou classe abstrata, não de implementações específicas. Isso elimina condicionais if/switch espalhados pelo código toda vez que você precisa criar um objeto diferente com base em um parâmetro.

❌ Sem Factory — acoplamento direto

O código cliente precisa conhecer todas as classes concretas e decidir qual instanciar. Cada novo tipo de notificação exige alterar este código.

✅ Com Factory — desacoplado

O cliente pede uma notificação para a factory e recebe um objeto que implementa a interface. Adicionar WhatsApp não altera o cliente.

 
src/Notificacoes/NotificacaoFactory.phpPHP
<?php
declare(strict_types=1);

namespace MeuApp\Notificacoes;

use InvalidArgumentException;

// Interface — o contrato que toda notificação deve cumprir
interface NotificacaoInterface
{
    public function enviar(string $destinatario, string $mensagem): bool;
    public function canal(): string;
}

// Implementações concretas
class NotificacaoEmail implements NotificacaoInterface
{
    public function enviar(string $dest, string $msg): bool
    {
        // Em produção: chamar um provedor como Mailgun, SendGrid, SES
        echo "📧 Email → {$dest}: {$msg}\n";
        return true;
    }
    public function canal(): string { return "email"; }
}

class NotificacaoSMS implements NotificacaoInterface
{
    public function enviar(string $dest, string $msg): bool
    {
        // Em produção: chamar Twilio, AWS SNS, Vonage
        echo "📱 SMS → {$dest}: {$msg}\n";
        return true;
    }
    public function canal(): string { return "sms"; }
}

class NotificacaoSlack implements NotificacaoInterface
{
    public function enviar(string $dest, string $msg): bool
    {
        echo "💬 Slack → #{$dest}: {$msg}\n";
        return true;
    }
    public function canal(): string { return "slack"; }
}

// Factory — cria a implementação correta com base em uma string
// O cliente não precisa conhecer nenhuma classe concreta
final class NotificacaoFactory
{
    // Mapa de tipo → classe — para adicionar WhatsApp: uma linha aqui
    private const TIPOS = [
        "email" => NotificacaoEmail::class,
        "sms"   => NotificacaoSMS::class,
        "slack" => NotificacaoSlack::class,
    ];

    public static function criar(string $tipo): NotificacaoInterface
    {
        if (!isset(self::TIPOS[$tipo])) {
            throw new InvalidArgumentException(
                "Canal desconhecido: '{$tipo}'. Disponíveis: " . implode(", ", array_keys(self::TIPOS))
            );
        }
        $classe = self::TIPOS[$tipo];
        return new $classe();
    }

    // Variante — cria múltiplos canais de uma vez
    public static function criarMultiplos(string ...$tipos): array
    {
        return array_map(self::criar(...), $tipos);
    }
}

// Uso — o cliente só conhece a interface e a factory
$canais = NotificacaoFactory::criarMultiplos("email", "sms", "slack");

foreach ($canais as $canal) {
    $canal->enviar("ana@email.com", "Pedido confirmado!");
}
// 📧 Email  → ana@email.com: Pedido confirmado!
// 📱 SMS   → ana@email.com: Pedido confirmado!
// 💬 Slack → #ana@email.com: Pedido confirmado!

// Lendo canal preferido de uma config, banco de dados, etc.
$canalPreferido = "sms"; // viria de $_ENV["NOTIFICACAO_CANAL"]
$n = NotificacaoFactory::criar($canalPreferido);
echo $n->canal();  // sms

Abstract Factory — fábrica de famílias

Quando você precisa criar famílias inteiras de objetos relacionados — por exemplo, um conjunto consistente de componentes de UI para diferentes temas — o Abstract Factory estende o conceito. Cada factory concreta produz objetos compatíveis entre si:

 
abstract-factory.phpPHP
<?php
declare(strict_types=1);

// Abstract Factory — cria famílias de objetos relacionados
// Exemplo: diferentes drivers de armazenamento com API consistente

interface StorageFactory
{
    public function criarLeitor(): Leitor;
    public function criarEscritor(): Escritor;
}

interface Leitor  { public function ler(string $chave): mixed; }
interface Escritor { public function escrever(string $chave, mixed $valor): void; }

// Família Redis
class RedisLeitor  implements Leitor  { public function ler(string $k): mixed  { return "[Redis:get {$k}]"; } }
class RedisEscritor implements Escritor { public function escrever(string $k, mixed $v): void { echo "[Redis:set {$k}={$v}]\n"; } }

// Família Arquivo
class ArquivoLeitor  implements Leitor  { public function ler(string $k): mixed  { return "[File:read {$k}]"; } }
class ArquivoEscritor implements Escritor { public function escrever(string $k, mixed $v): void { echo "[File:write {$k}={$v}]\n"; } }

class RedisFactory   implements StorageFactory { public function criarLeitor(): Leitor { return new RedisLeitor();  } public function criarEscritor(): Escritor { return new RedisEscritor();  } }
class ArquivoFactory implements StorageFactory { public function criarLeitor(): Leitor { return new ArquivoLeitor(); } public function criarEscritor(): Escritor { return new ArquivoEscritor(); } }

// Código cliente usa só a interface — pode trocar Redis por Arquivo
// sem modificar uma linha desta função
function executarCache(StorageFactory $factory): void
{
    $escritor = $factory->criarEscritor();
    $leitor   = $factory->criarLeitor();
    $escritor->escrever("sessao", "abc123");
    echo $leitor->ler("sessao") . "\n";
}

executarCache(new RedisFactory());
// [Redis:set sessao=abc123]
// [Redis:get sessao]

executarCache(new ArquivoFactory());
// [File:write sessao=abc123]
// [File:read sessao]

Builder — construindo objetos complexos

O Builder separa a construção de um objeto complexo da sua representação final. É a solução elegante para o problema do "construtor telescópico" — quando uma classe precisa de muitos parâmetros opcionais, criar um construtor com 10 argumentos é impraticável e propenso a erros (fácil trocar a ordem dos argumentos). O Builder oferece uma API fluente onde cada parâmetro opcional é um método com nome descritivo.

 
src/Mail/EmailBuilder.phpPHP
<?php
declare(strict_types=1);

namespace MeuApp\Mail;

// O produto — objeto final imutável que o Builder constrói
final class Email
{
    public function __construct(
        public readonly string $de,
        public readonly string $para,
        public readonly string $assunto,
        public readonly string $corpo,
        public readonly array  $cc         = [],
        public readonly array  $bcc        = [],
        public readonly array  $anexos     = [],
        public readonly bool   $html       = false,
        public readonly ?string $replyTo   = null,
        public readonly int    $prioridade = 3,
    ) {}
}

// Builder — constrói o Email passo a passo com API fluente
final class EmailBuilder
{
    private string $de;
    private string $para;
    private string $assunto;
    private string $corpo;
    private array  $cc         = [];
    private array  $bcc        = [];
    private array  $anexos     = [];
    private bool   $html       = false;
    private ?string $replyTo  = null;
    private int    $prioridade = 3;

    // Construtor factory estático — ponto de entrada semântico
    public static function novo(string $de, string $para): static
    {
        $b       = new static();
        $b->de   = $de;
        $b->para = $para;
        return $b;
    }

    // Cada método retorna $this — permite encadeamento fluente
    public function assunto(string $assunto): static
    {
        $this->assunto = $assunto;
        return $this;
    }

    public function corpo(string $corpo, bool $html = false): static
    {
        $this->corpo = $corpo;
        $this->html  = $html;
        return $this;
    }

    public function cc(string ...$enderecos): static
    {
        $this->cc = array_merge($this->cc, $enderecos);
        return $this;
    }

    public function bcc(string ...$enderecos): static
    {
        $this->bcc = array_merge($this->bcc, $enderecos);
        return $this;
    }

    public function anexar(string $caminhoArquivo): static
    {
        $this->anexos[] = $caminhoArquivo;
        return $this;
    }

    public function replyTo(string $endereco): static
    {
        $this->replyTo = $endereco;
        return $this;
    }

    public function urgente(): static
    {
        $this->prioridade = 1; // 1 = alta, 3 = normal, 5 = baixa
        return $this;
    }

    // build() — valida e cria o objeto final imutável
    public function build(): Email
    {
        if (empty($this->assunto)) {
            throw new \InvalidArgumentException("Email precisa de assunto.");
        }
        if (empty($this->corpo)) {
            throw new \InvalidArgumentException("Email precisa de corpo.");
        }
        return new Email(
            $this->de,
            $this->para,
            $this->assunto,
            $this->corpo,
            $this->cc,
            $this->bcc,
            $this->anexos,
            $this->html,
            $this->replyTo,
            $this->prioridade,
        );
    }
}

// Uso — API fluente legível como uma frase
$email = EmailBuilder::novo("sistema@app.com", "cliente@email.com")
    ->assunto("Confirmação do pedido #1234")
    ->corpo("<h1>Pedido confirmado!</h1><p>Obrigado pela compra.</p>", true)
    ->cc("gerente@app.com")
    ->bcc("auditoria@app.com")
    ->anexar("/tmp/nota-fiscal-1234.pdf")
    ->replyTo("suporte@app.com")
    ->build();

echo $email->assunto;               // Confirmação do pedido #1234
var_dump($email->html);               // bool(true)
var_dump($email->anexos);             // ["/tmp/nota-fiscal-1234.pdf"]

// Email urgente — só adiciona o que precisa
$alerta = EmailBuilder::novo("sistema@app.com", "ops@app.com")
    ->assunto("🚨 Servidor fora do ar")
    ->corpo("Disco cheio em prod-01. Ação imediata necessária.")
    ->urgente()
    ->build();

var_dump($alerta->prioridade);  // int(1)

Builder com Director

Quando certas configurações de Build se repetem muito — como um "email de boas-vindas" ou uma "query de relatório mensal" — um Director encapsula a sequência de chamadas ao Builder, criando um ponto reutilizável para construções pré-definidas:

 
director.phpPHP
<?php
declare(strict_types=1);

use MeuApp\Mail\EmailBuilder;
use MeuApp\Mail\Email;

// Director — conhece as "receitas" de construção mais comuns
final class EmailDirector
{
    public static function boasVindas(string $para, string $nome): Email
    {
        return EmailBuilder::novo("noreply@app.com", $para)
            ->assunto("Bem-vindo ao App, {$nome}!")
            ->corpo("<h1>Olá, {$nome}!</h1><p>Sua conta foi criada com sucesso.</p>", true)
            ->replyTo("suporte@app.com")
            ->build();
    }

    public static function alertaCritico(string $para, string $mensagem): Email
    {
        return EmailBuilder::novo("sistema@app.com", $para)
            ->assunto("🚨 ALERTA CRÍTICO — " . date("d/m H:i"))
            ->corpo($mensagem)
            ->bcc("diretoria@app.com")
            ->urgente()
            ->build();
    }
}

// Uso — limpo, intencional, sem repetição
$emailBv    = EmailDirector::boasVindas("joao@email.com", "João");
$emailAlerta = EmailDirector::alertaCritico("ops@app.com", "Memória RAM em 95%");
✅ Builder no mundo real — QueryBuilder do Laravel / Doctrine

O QueryBuilder do Laravel (DB::table('users')->where('ativo', true)->orderBy('nome')->get()) é exatamente o padrão Builder. Cada where(), orderBy(), limit() acumula a configuração, e o get() (equivalente ao build()) compila tudo em SQL e executa. O mesmo vale para o QueryBuilder do Doctrine ORM e para clientes HTTP como Guzzle.

Dos três, o Singleton é o único que exige uma ressalva permanente: ele resolve mesmo o problema de instância única, e cria outro maior, o da dependência que ninguém vê na assinatura. Quando o objetivo é economizar conexões ou compartilhar configuração, o contêiner de injeção entrega o mesmo resultado sem o estado global — e é por isso que você vai encontrá-lo em todo framework moderno e não vai encontrar Singletons. Conhecer o padrão continua valendo: ele está em praticamente todo sistema PHP com mais de dez anos, e agora você sabe por onde começar a desmontá-lo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Implemente um Singleton para gerenciar configurações do sistema (Config). Ele deve carregar um arquivo config.php que retorna um array na primeira chamada e expor métodos get(string $chave, mixed $padrao = null): mixed e set(string $chave, mixed $valor): void. Verifique com === que duas chamadas a Config::getInstance() retornam o mesmo objeto.

Ver resposta

✓ Resposta: A última linha é o que o === do enunciado prova: $b vê o que $a escreveu porque são o mesmo objeto. Vale saber o outro lado: esse estado global compartilhado é justamente o que torna Singleton difícil de testar — em código novo, injeção de dependência costuma ser a escolha melhor.

<?php

declare(strict_types=1);

final class Config
{
    private static ?self $instancia = null;

    private array $dados = [];

    // Os três bloqueios que fazem o Singleton valer: sem `new`, sem `clone`
    // e sem `unserialize` criando uma segunda instância pelas costas.
    private function __construct()
    {
        $caminho = __DIR__ . '/config.php';

        if (!is_file($caminho)) {
            throw new RuntimeException("config.php não encontrado em {$caminho}");
        }

        $this->dados = require $caminho;   // o arquivo retorna um array
    }

    private function __clone() {}

    public function __wakeup(): void
    {
        throw new LogicException('Config não pode ser desserializado.');
    }

    public static function getInstance(): self
    {
        // O carregamento do arquivo acontece uma vez só, na primeira chamada.
        return self::$instancia ??= new self();
    }

    /** Aceita chave com ponto: get('db.host') */
    public function get(string $chave, mixed $padrao = null): mixed
    {
        $atual = $this->dados;

        foreach (explode('.', $chave) as $parte) {
            if (!is_array($atual) || !array_key_exists($parte, $atual)) {
                return $padrao;
            }
            $atual = $atual[$parte];
        }

        return $atual;
    }

    public function set(string $chave, mixed $valor): void
    {
        $partes = explode('.', $chave);
        $atual = &$this->dados;

        foreach ($partes as $parte) {
            if (!isset($atual[$parte]) || !is_array($atual[$parte])) {
                $atual[$parte] = [];
            }
            $atual = &$atual[$parte];
        }

        $atual = $valor;
    }
}

// config.php seria:
// <?php return ['app' => ['nome' => 'Loja'], 'db' => ['host' => '127.0.0.1']];

$a = Config::getInstance();
$b = Config::getInstance();

var_dump($a === $b);                  // bool(true) — mesma instância
echo $a->get('db.host'), PHP_EOL;     // 127.0.0.1
echo $a->get('db.porta', 3306), PHP_EOL;  // 3306 (padrão)

$a->set('db.porta', 5432);
echo $b->get('db.porta'), PHP_EOL;    // 5432 — b enxerga o que a escreveu

Exercício 2

Crie uma Factory para parsers de arquivo: interface ParserInterface com método parse(string $conteudo): array. Implemente JsonParser, CsvParser e XmlParser. A ParserFactory::criar(string $extensao) retorna o parser correto baseado na extensão (.json, .csv, .xml). Lança ParserNaoSuportadoException para extensões desconhecidas.

Ver resposta

✓ Resposta: Quem chama a factory nunca escreve new JsonParser: recebe ParserInterface e pronto. Formato novo é uma linha no mapa — e o throw como expressão no ?? evita o if de sempre.

<?php

declare(strict_types=1);

interface ParserInterface
{
    /** @return array<int|string, mixed> */
    public function parse(string $conteudo): array;
}

final class ParserNaoSuportadoException extends InvalidArgumentException
{
    public function __construct(public readonly string $extensao)
    {
        parent::__construct("Não há parser para a extensão '{$extensao}'.");
    }
}

final class JsonParser implements ParserInterface
{
    public function parse(string $conteudo): array
    {
        // JSON_THROW_ON_ERROR troca o retorno null silencioso por exceção.
        return json_decode($conteudo, true, 512, JSON_THROW_ON_ERROR);
    }
}

final class CsvParser implements ParserInterface
{
    public function __construct(private readonly string $separador = ',') {}

    public function parse(string $conteudo): array
    {
        $linhas = preg_split('/\r\n|\r|\n/', trim($conteudo));
        $cabecalho = str_getcsv(array_shift($linhas), $this->separador);

        return array_map(
            fn(string $linha): array => array_combine(
                $cabecalho,
                str_getcsv($linha, $this->separador),
            ),
            array_filter($linhas, static fn(string $l): bool => trim($l) !== ''),
        );
    }
}

final class XmlParser implements ParserInterface
{
    public function parse(string $conteudo): array
    {
        $anterior = libxml_use_internal_errors(true);

        // LIBXML_NONET fecha a porta de XXE — XML de terceiro não deve
        // conseguir buscar entidade externa pela rede.
        $xml = simplexml_load_string($conteudo, null, LIBXML_NONET | LIBXML_NOCDATA);

        libxml_use_internal_errors($anterior);

        if ($xml === false) {
            throw new RuntimeException('XML inválido.');
        }

        return json_decode(json_encode($xml), true);
    }
}

final class ParserFactory
{
    private const MAPA = [
        'json' => JsonParser::class,
        'csv'  => CsvParser::class,
        'xml'  => XmlParser::class,
    ];

    public static function criar(string $extensao): ParserInterface
    {
        // Normaliza ".JSON", "json" e ".json" para a mesma chave.
        $chave = strtolower(ltrim($extensao, '.'));

        $classe = self::MAPA[$chave] ?? throw new ParserNaoSuportadoException($chave);

        return new $classe();
    }

    public static function paraArquivo(string $caminho): ParserInterface
    {
        return self::criar(pathinfo($caminho, PATHINFO_EXTENSION));
    }
}

// Uso:
$parser = ParserFactory::criar('.json');
print_r($parser->parse('{"nome":"Onix","preco":89900}'));

$csv = ParserFactory::criar('csv');
print_r($csv->parse("nome,preco\nOnix,89900\nHB20,87500"));

try {
    ParserFactory::criar('.docx');
} catch (ParserNaoSuportadoException $e) {
    echo $e->getMessage(), PHP_EOL;   // Não há parser para a extensão 'docx'.
}

Exercício 3

Construa um Builder para gerar queries SQL SELECT: QueryBuilder com métodos from(string $tabela), select(string ...$colunas), where(string $condicao), orderBy(string $coluna, string $direcao = 'ASC'), limit(int $n) e build(): string. O build() retorna a string SQL completa e válida.

Ver resposta

✓ Resposta: Repare que where() aqui recebe a condição pronta — é o que o enunciado pede, e serve para aprender a montagem. Em código de produção, valor vindo do usuário nunca entra por concatenação: vira placeholder, como no QueryBuilder do artigo sobre PDO.

<?php

declare(strict_types=1);

final class QueryBuilder
{
    private string $tabela = '';
    private array $colunas = ['*'];
    private array $condicoes = [];
    private array $ordenacao = [];
    private ?int $limite = null;

    public function from(string $tabela): static
    {
        $this->tabela = $tabela;
        return $this;
    }

    public function select(string ...$colunas): static
    {
        if ($colunas !== []) {
            $this->colunas = $colunas;
        }
        return $this;
    }

    public function where(string $condicao): static
    {
        $this->condicoes[] = $condicao;
        return $this;
    }

    public function orderBy(string $coluna, string $direcao = 'ASC'): static
    {
        $direcao = strtoupper($direcao);

        if (!in_array($direcao, ['ASC', 'DESC'], true)) {
            throw new InvalidArgumentException("Direção inválida: {$direcao}");
        }

        $this->ordenacao[] = "{$coluna} {$direcao}";
        return $this;
    }

    public function limit(int $n): static
    {
        if ($n < 1) {
            throw new InvalidArgumentException('LIMIT tem de ser >= 1.');
        }

        $this->limite = $n;
        return $this;
    }

    public function build(): string
    {
        if ($this->tabela === '') {
            throw new LogicException('Defina a tabela com from() antes de build().');
        }

        // A ordem das cláusulas é fixa em SQL — o builder garante isso mesmo
        // que os métodos tenham sido chamados fora de ordem.
        $sql = 'SELECT ' . implode(', ', $this->colunas)
             . ' FROM ' . $this->tabela;

        if ($this->condicoes !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $this->condicoes);
        }
        if ($this->ordenacao !== []) {
            $sql .= ' ORDER BY ' . implode(', ', $this->ordenacao);
        }
        if ($this->limite !== null) {
            $sql .= ' LIMIT ' . $this->limite;
        }

        return $sql;
    }
}

// O `return $this` de cada método é o que permite a corrente:
echo (new QueryBuilder())
    ->select('id', 'nome', 'preco')
    ->from('produtos')
    ->where('ativo = 1')
    ->where('preco > 5000')
    ->orderBy('preco', 'DESC')
    ->limit(10)
    ->build();

// SELECT id, nome, preco FROM produtos
// WHERE ativo = 1 AND preco > 5000 ORDER BY preco DESC LIMIT 10

Exercício 4

Crie um Director para o QueryBuilder do exercício 3 com dois métodos: queryPaginada(string $tabela, int $pagina, int $porPagina): string e queryRecentes(string $tabela, string $campoData, int $limite): string.

Ver resposta

✓ Resposta: A divisão de trabalho do padrão: o Builder sabe como montar cada pedaço, o Director sabe quais pedaços formam uma receita conhecida. O erro de página zero morre no Director, não vira SQL com OFFSET negativo.

<?php

declare(strict_types=1);

/**
 * O Director guarda as receitas de montagem que se repetem.
 * Quem chama não precisa lembrar que paginação é OFFSET = (página-1) * porPágina.
 */
final class QueryDirector
{
    public function queryPaginada(string $tabela, int $pagina, int $porPagina): string
    {
        if ($pagina < 1) {
            throw new InvalidArgumentException('A página começa em 1.');
        }

        $offset = ($pagina - 1) * $porPagina;

        $sql = (new QueryBuilder())
            ->from($tabela)
            ->orderBy('id')
            ->limit($porPagina)
            ->build();

        // OFFSET não está no builder do exercício 3 — sai daqui.
        return $offset > 0 ? "{$sql} OFFSET {$offset}" : $sql;
    }

    public function queryRecentes(string $tabela, string $campoData, int $limite): string
    {
        return (new QueryBuilder())
            ->from($tabela)
            ->orderBy($campoData, 'DESC')
            ->limit($limite)
            ->build();
    }
}

$director = new QueryDirector();

echo $director->queryPaginada('produtos', pagina: 3, porPagina: 20), PHP_EOL;
// SELECT * FROM produtos ORDER BY id ASC LIMIT 20 OFFSET 40

echo $director->queryRecentes('pedidos', 'criado_em', 5), PHP_EOL;
// SELECT * FROM pedidos ORDER BY criado_em DESC LIMIT 5

Exercício 5

Desafio: combine os três padrões num mini-sistema de relatórios. A RelatorioFactory cria implementações de RelatorioInterface (PDF, CSV, HTML). Cada relatório é configurado por um RelatorioBuilder com filtros de data, colunas visíveis e ordenação. O ConfiguracaoRelatorio é um Singleton que guarda os formatos disponíveis e os limites de linhas por relatório.

Ver resposta

✓ Resposta: Cada padrão responde por uma pergunta diferente e não invade a do vizinho: a Factory decide qual classe nasce, o Builder decide com que configuração, e o Singleton guarda o que é global de verdade. Quando um padrão começa a responder a pergunta do outro, é sinal de que o desenho azedou.

<?php

declare(strict_types=1);

// ---------- SINGLETON: limites e formatos, um só para a aplicação ----------
final class ConfiguracaoRelatorio
{
    private static ?self $instancia = null;

    private array $formatos = ['pdf', 'csv', 'html'];
    private int $limiteLinhas = 5000;

    private function __construct() {}
    private function __clone() {}

    public static function getInstance(): self
    {
        return self::$instancia ??= new self();
    }

    public function formatos(): array { return $this->formatos; }

    public function limiteLinhas(): int { return $this->limiteLinhas; }

    public function definirLimite(int $linhas): void
    {
        $this->limiteLinhas = max(1, $linhas);
    }
}

// ---------- STRATEGY/FACTORY: um relatório por formato --------------------
interface RelatorioInterface
{
    public function render(array $linhas, array $colunas): string;

    public function extensao(): string;
}

final class RelatorioCsv implements RelatorioInterface
{
    public function render(array $linhas, array $colunas): string
    {
        $saida = fopen('php://temp', 'r+');
        fputcsv($saida, $colunas);

        foreach ($linhas as $linha) {
            // Só as colunas visíveis, na ordem pedida.
            fputcsv($saida, array_map(fn($c) => $linha[$c] ?? '', $colunas));
        }

        rewind($saida);
        $conteudo = stream_get_contents($saida);
        fclose($saida);

        return $conteudo;
    }

    public function extensao(): string { return 'csv'; }
}

final class RelatorioHtml implements RelatorioInterface
{
    public function render(array $linhas, array $colunas): string
    {
        $html = "<table>\n<thead><tr>";

        foreach ($colunas as $coluna) {
            // htmlspecialchars em TODO dado que vai para a página.
            $html .= '<th>' . htmlspecialchars((string) $coluna, ENT_QUOTES) . '</th>';
        }

        $html .= "</tr></thead>\n<tbody>\n";

        foreach ($linhas as $linha) {
            $html .= '<tr>';
            foreach ($colunas as $coluna) {
                $html .= '<td>' . htmlspecialchars((string) ($linha[$coluna] ?? ''), ENT_QUOTES) . '</td>';
            }
            $html .= "</tr>\n";
        }

        return $html . "</tbody>\n</table>";
    }

    public function extensao(): string { return 'html'; }
}

final class RelatorioPdf implements RelatorioInterface
{
    // Sem dependência externa aqui: gera o corpo que uma lib (Dompdf, mPDF)
    // receberia. O padrão é o mesmo, muda só o motor de saída.
    public function render(array $linhas, array $colunas): string
    {
        $corpo = (new RelatorioHtml())->render($linhas, $colunas);

        return "%PDF-1.4 (simulado)\n" . strip_tags($corpo, '<table><tr><td><th>');
    }

    public function extensao(): string { return 'pdf'; }
}

final class RelatorioFactory
{
    public static function criar(string $formato): RelatorioInterface
    {
        $formato = strtolower($formato);

        if (!in_array($formato, ConfiguracaoRelatorio::getInstance()->formatos(), true)) {
            throw new InvalidArgumentException("Formato não habilitado: {$formato}");
        }

        return match ($formato) {
            'csv'  => new RelatorioCsv(),
            'html' => new RelatorioHtml(),
            'pdf'  => new RelatorioPdf(),
        };
    }
}

// ---------- BUILDER: filtros, colunas e ordenação -------------------------
final class RelatorioBuilder
{
    private ?DateTimeImmutable $de = null;
    private ?DateTimeImmutable $ate = null;
    private array $colunas = [];
    private ?string $ordenarPor = null;
    private string $direcao = 'ASC';

    public function periodo(DateTimeImmutable $de, DateTimeImmutable $ate): static
    {
        if ($de > $ate) {
            throw new InvalidArgumentException('Início posterior ao fim do período.');
        }

        $this->de = $de;
        $this->ate = $ate;
        return $this;
    }

    public function colunas(string ...$colunas): static
    {
        $this->colunas = $colunas;
        return $this;
    }

    public function ordenarPor(string $coluna, string $direcao = 'ASC'): static
    {
        $this->ordenarPor = $coluna;
        $this->direcao = strtoupper($direcao) === 'DESC' ? 'DESC' : 'ASC';
        return $this;
    }

    public function gerar(array $dados, RelatorioInterface $relatorio): string
    {
        $linhas = $dados;

        if ($this->de !== null) {
            $linhas = array_filter($linhas, function (array $l): bool {
                $data = new DateTimeImmutable($l['data']);
                return $data >= $this->de && $data <= $this->ate;
            });
        }

        if ($this->ordenarPor !== null) {
            $col = $this->ordenarPor;
            $dir = $this->direcao === 'DESC' ? -1 : 1;
            usort($linhas, fn(array $a, array $b): int => ($a[$col] <=> $b[$col]) * $dir);
        }

        // O Singleton é a última palavra sobre o tamanho do relatório.
        $linhas = array_slice($linhas, 0, ConfiguracaoRelatorio::getInstance()->limiteLinhas());

        $colunas = $this->colunas ?: array_keys($linhas[0] ?? []);

        return $relatorio->render($linhas, $colunas);
    }
}

// ---------- Juntando tudo -------------------------------------------------
$dados = [
    ['data' => '2026-03-01', 'produto' => 'Onix', 'total' => 89900],
    ['data' => '2026-03-14', 'produto' => 'HB20', 'total' => 87500],
    ['data' => '2026-04-02', 'produto' => 'Kwid', 'total' => 71200],
];

echo (new RelatorioBuilder())
    ->periodo(new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'))
    ->colunas('data', 'produto', 'total')
    ->ordenarPor('total', 'DESC')
    ->gerar($dados, RelatorioFactory::criar('csv'));

// data,produto,total
// 2026-03-01,Onix,89900
// 2026-03-14,HB20,87500
Comentários

Mais em PHP

Observer, Decorator e Strategy
Observer, Decorator e Strategy

Três padrões que aparecem em praticamente qualquer sistema: o Observer, que…

Estruturas de Repetição
Estruturas de Repetição

Repetir é onde o PHP oferece mais caminhos para o mesmo destino: for quando o…

Traits Avançados
Traits Avançados

Trait resolve o que herança não alcança, e traz regras próprias: quem vence…