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

Traits Avançados
Traits Avançados

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

Filas e Jobs Assíncronos
Filas e Jobs Assíncronos

Uma fila sobre Redis, com envelope de metadados, retry com backoff…

ORM com Eloquent
ORM com Eloquent

Relacionamento em uma linha, cast automático de tipos, escopo que se encadeia…