PSR e Padrões de Código

PSR e Padrões de Código

Por que as recomendações do PHP-FIG existem e o que muda no seu código ao seguir cada uma. Com um container que implementa a interface sem cumprir o contrato dela, a opção do PHPStan que sumiu na versão 2 e as ferramentas que pegam isso antes de você.
PHP

• • 28 min de leitura

PSR significa PHP Standards Recommendation — recomendações criadas pelo PHP-FIG (Framework Interoperability Group), um grupo formado pelos mantenedores dos principais frameworks e bibliotecas PHP. O objetivo é garantir interoperabilidade: que componentes de diferentes projetos funcionem juntos sem atrito.

Conhecer as PSRs mais importantes não é pedantismo acadêmico. É falar a língua da comunidade PHP profissional, entender como frameworks como Laravel e Symfony funcionam por dentro, e escrever código que qualquer desenvolvedor PHP experiente vai reconhecer imediatamente.

PSR-1 e PSR-12 — Estilo de código

PSR-1 define o básico: arquivos PHP usam apenas <?php ou <?=, declaram classes ou executam efeitos (não ambos no mesmo arquivo), e seguem StudlyCaps para nomes de classes.

PSR-12 é a extensão moderna que substitui a PSR-2. Os pontos principais:

<?php
declare(strict_types=1);          // declare logo após a abertura

namespace App\Domain\Pedido;      // linha em branco antes e depois

use App\Domain\Shared\Dinheiro;   // use statements agrupados
use DateTimeImmutable;

class Pedido                      // { na mesma linha da declaração
{                                 // exceto para classes, métodos e funções
    private const STATUS_PENDENTE = 'pendente';

    private array $itens = [];    // 4 espaços de indentação (não tabs)

    public function __construct(  // parâmetros longos: um por linha
        private readonly int $id,
        private string $status = self::STATUS_PENDENTE,
        private readonly DateTimeImmutable $criadoEm = new DateTimeImmutable(),
    ) {}

    public function adicionarItem(   // visibilidade sempre declarada
        int $produtoId,
        int $quantidade,
        Dinheiro $preco,
    ): void {
        if ($quantidade <= 0) {      // espaço antes do (
            throw new \InvalidArgumentException('Quantidade inválida.');
        }                            // } na própria linha

        $this->itens[] = [
            'produto_id' => $produtoId,
            'quantidade' => $quantidade,
            'preco'      => $preco,
        ];
    }

    public function total(): Dinheiro
    {
        return array_reduce(
            $this->itens,
            fn(Dinheiro $acc, array $item) => $acc->somar(
                $item['preco']->multiplicar($item['quantidade'])
            ),
            Dinheiro::de(0),
        );
    }
}

PHP CS Fixer — formatação automática:

composer require --dev friendsofphp/php-cs-fixer

# Cria o arquivo de configuração .php-cs-fixer.php
cat > .php-cs-fixer.php << 'EOF'
<?php
return (new PhpCsFixer\Config())
    ->setRules([
        '@PSR12'                         => true,
        'declare_strict_types'           => true,
        'array_syntax'                   => ['syntax' => 'short'],
        'ordered_imports'                => ['sort_algorithm' => 'alpha'],
        'no_unused_imports'              => true,
        'trailing_comma_in_multiline'    => true,
    ])
    ->setFinder(PhpCsFixer\Finder::create()->in('src')->in('tests'));
EOF

# Verificar sem alterar
vendor/bin/php-cs-fixer fix --dry-run --diff

# Corrigir automaticamente
vendor/bin/php-cs-fixer fix

PSR-3 — Interface de Logger

PSR-3 define uma interface padrão para logging. Qualquer biblioteca que aceite um LoggerInterface funciona com Monolog, ou com qualquer outro logger PSR-3 compatível:

<?php
use Psr\Log\LoggerInterface;

class ProcessadorDePedidos
{
    // Recebe a interface, não a implementação concreta
    public function __construct(private readonly LoggerInterface $logger) {}

    public function processar(Pedido $pedido): void
    {
        $this->logger->info('Processando pedido', ['id' => $pedido->id]);

        try {
            // processamento...
            $this->logger->info('Pedido processado com sucesso', ['id' => $pedido->id]);
        } catch (\Exception $e) {
            $this->logger->error('Falha ao processar pedido', [
                'id'    => $pedido->id,
                'erro'  => $e->getMessage(),
                'trace' => $e->getTraceAsString(),
            ]);
            throw $e;
        }
    }
}

// Níveis PSR-3 (do menos para o mais grave):
// debug, info, notice, warning, error, critical, alert, emergency

PSR-7 — HTTP Messages

PSR-7 define interfaces para representar requisições e respostas HTTP como objetos imutáveis. É a base de frameworks como Slim, Laminas e middlewares PSR-15:

<?php
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;

// Um handler de rota recebe uma Request e retorna uma Response
function listarUsuarios(ServerRequestInterface $request): ResponseInterface
{
    $queryParams = $request->getQueryParams();
    $pagina      = (int) ($queryParams['pagina'] ?? 1);

    $usuarios = buscarUsuarios($pagina);

    // Response é imutável — withHeader retorna uma nova instância
    $response = new \Nyholm\Psr7\Response();
    $response = $response
        ->withHeader('Content-Type', 'application/json')
        ->withStatus(200);

    $response->getBody()->write(json_encode($usuarios));

    return $response;
}

PSR-11 — Container de Injeção de Dependência

PSR-11 define a interface para containers de DI. Laravel, Symfony e PHP-DI todos implementam esta interface:

<?php
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;

class ServiceLocator
{
    public function __construct(private readonly ContainerInterface $container) {}

    public function obterServico(string $id): mixed
    {
        if (!$this->container->has($id)) {
            throw new ContainerNaoEncontrado("Serviço não registrado: {$id}");
        }
        return $this->container->get($id);
    }
}

// Implementação simples de container.
//
// Repare nas exceções: a PSR-11 não deixa isso em aberto. O get() precisa
// lançar NotFoundExceptionInterface quando o identificador não existe, e
// ContainerExceptionInterface para qualquer outra falha. Uma RuntimeException
// genérica compila e funciona, mas quebra o contrato — e código escrito
// para trocar de container não consegue capturar o erro certo.
class ContainerNaoEncontrado extends \RuntimeException implements NotFoundExceptionInterface {}

class Container implements ContainerInterface
{
    private array $bindings  = [];
    private array $instancias = [];

    public function bind(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    public function singleton(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = function () use ($abstract, $factory) {
            if (!isset($this->instancias[$abstract])) {
                $this->instancias[$abstract] = $factory($this);
            }
            return $this->instancias[$abstract];
        };
    }

    public function get(string $id): mixed
    {
        if (!$this->has($id)) {
            throw new ContainerNaoEncontrado("Não encontrado: {$id}");
        }
        return ($this->bindings[$id])($this);
    }

    public function has(string $id): bool
    {
        return isset($this->bindings[$id]);
    }
}

// Configurando o container:
$container = new Container();

$container->singleton(PDO::class, fn() => new PDO(
    'pgsql:host=localhost;dbname=loja',
    'user', 'pass',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
));

$container->bind(
    UsuarioRepositorioInterface::class,
    fn(ContainerInterface $c) => new UsuarioRepositorioPDO($c->get(PDO::class))
);

$container->bind(
    ProcessadorDePedidos::class,
    fn(ContainerInterface $c) => new ProcessadorDePedidos($c->get(LoggerInterface::class))
);

PHPStan — análise estática de tipos

PHPStan analisa o código sem executá-lo, detectando erros de tipo, chamadas a métodos inexistentes, variáveis não definidas e muitos outros problemas:

composer require --dev phpstan/phpstan

# Configura em phpstan.neon
cat > phpstan.neon << 'EOF'
parameters:
    # No PHPStan 1.x os níveis iam de 0 a 9; a versão 2 acrescentou o 10.
    level: 8
    paths:
        - src
    # A opção checkMissingIterableValueType, que costumava aparecer aqui para
    # silenciar a cobrança de tipo em array, foi REMOVIDA no PHPStan 2 — com
    # ela no arquivo, a análise nem começa. O caminho agora é anotar os
    # arrays de verdade: @param string[] $nomes, @return array<int, Pedido>.
EOF

vendor/bin/phpstan analyse
<?php
// PHPStan detecta isso como erro no nível 5+:

function processarId(int $id): string
{
    $resultado = buscarNoBanco($id); // retorna string|null

    return strtoupper($resultado); // ERRO: argumento pode ser null
}

// Correto:
function processarId(int $id): string
{
    $resultado = buscarNoBanco($id);

    if ($resultado === null) {
        throw new \RuntimeException("ID {$id} não encontrado.");
    }

    return strtoupper($resultado);
}

Resumo das PSRs mais importantes

PSR Assunto Uso prático
PSR-1 Estilo básico Base de todo código PHP profissional
PSR-4 Autoloading Mapeamento namespace → diretório
PSR-3 Logger Interface comum para logging
PSR-7 HTTP Messages Requisições/respostas imutáveis
PSR-11 Container DI Interface comum para containers
PSR-12 Estilo estendido Formatação consistente do código

As PSRs não são regras de etiqueta: cada uma existe para que pedaços de código escritos por gente diferente se encaixem sem adaptador. A PSR-3 é o exemplo mais claro — sua classe pede uma LoggerInterface e passa a aceitar Monolog, o logger do Symfony ou um dublê de teste que guarda as mensagens num array, sem que nenhuma linha mude. O ganho não é estético, é poder trocar de implementação sem reescrever o que depende dela.

E aderir a um padrão é mais do que declarar a interface. Um container que implementa ContainerInterface mas lança RuntimeException em vez de NotFoundExceptionInterface compila, roda e engana: quem escreveu catch confiando na especificação não captura nada. É o tipo de descuido que as ferramentas deste artigo pegam antes de você — o CS Fixer no estilo, o PHPStan nos tipos —, desde que estejam configuradas com a versão que você realmente instalou.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Instale friendsofphp/php-cs-fixer e configure para PSR-12 + declare_strict_types. Rode em um projeto existente e analise as diferenças reportadas.

Ver resposta

✓ Resposta: A separação que evita a dor de cabeça: rodar primeiro sem as regras risky e commitar só a formatação; depois, num commit à parte, ligar declare_strict_types e strict_comparison. Essas duas mudam comportamento — com strict_types, somar("42") passa a lançar TypeError em vez de converter calado. Misturar as duas coisas num commit só transforma qualquer git bisect futuro em arqueologia.

<?php

// ============ no terminal ============
// composer require --dev friendsofphp/php-cs-fixer
//
// vendor/bin/php-cs-fixer fix --dry-run --diff    ← só mostra o que mudaria
// vendor/bin/php-cs-fixer fix                     ← aplica

// ---------------------------------------- .php-cs-fixer.dist.php
declare(strict_types=1);

// O arquivo é PHP de verdade — dá para calcular a configuração, não só
// declarar. O sufixo `.dist` é convenção: versione este, e deixe cada pessoa
// sobrescrever localmente num `.php-cs-fixer.php` fora do git.

$finder = PhpCsFixer\Finder::create()
    ->in([__DIR__ . '/src', __DIR__ . '/tests'])
    ->exclude(['Legado'])
    // Migration já aplicada em produção não se reformata: o diff vira ruído
    // eterno em toda revisão de código.
    ->notPath('database/migrations')
    ->name('*.php');

return (new PhpCsFixer\Config())
    ->setRiskyAllowed(true)
    ->setFinder($finder)
    // Cache versionada no .gitignore: sem ela, a checagem de CI reprocessa
    // o projeto inteiro a cada push.
    ->setCacheFile(__DIR__ . '/var/.php-cs-fixer.cache')
    ->setRules([
        // A base. Tudo abaixo é ajuste em cima dela.
        '@PSR12' => true,

        // ---- risky: mudam COMPORTAMENTO, não só formatação ----
        // Por isso exigem setRiskyAllowed(true) e uma suíte de testes verde
        // antes de rodar pela primeira vez.
        'declare_strict_types'      => true,
        'strict_comparison'         => true,   // == vira ===
        'strict_param'              => true,   // in_array() ganha o 3º argumento
        'modernize_strpos'          => true,   // strpos(...) !== false → str_contains
        'no_unreachable_default_argument_value' => true,

        // ---- organização de imports ----
        'global_namespace_import'   => ['import_classes' => true,
                                        'import_functions' => false,
                                        'import_constants' => false],
        'ordered_imports'           => ['sort_algorithm' => 'alpha',
                                        'imports_order' => ['class', 'function', 'const']],
        'no_unused_imports'         => true,

        // ---- legibilidade ----
        'array_syntax'              => ['syntax' => 'short'],
        'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
        'binary_operator_spaces'    => ['default' => 'single_space',
                                        'operators' => ['=>' => 'align_single_space_minimal']],
        'concat_space'              => ['spacing' => 'one'],
        'phpdoc_align'              => ['align' => 'left'],
        'phpdoc_separation'         => true,
        // Docblock que só repete a assinatura é ruído: o fixer remove.
        'no_superfluous_phpdoc_tags' => ['allow_mixed' => true,
                                         'remove_inheritdoc' => true],
        'single_line_comment_style' => ['comment_types' => ['hash']],

        // ---- classes ----
        'ordered_class_elements'    => ['order' => [
            'use_trait', 'case', 'constant_public', 'constant_protected',
            'constant_private', 'property_public', 'property_protected',
            'property_private', 'construct', 'destruct', 'magic',
            'method_public', 'method_protected', 'method_private',
        ]],
        'visibility_required'       => ['elements' => ['property', 'method', 'const']],
        'self_accessor'             => true,
    ]);

// ============ o que aparece no primeiro --dry-run --diff ============
//
// Num projeto que nunca passou por formatador, o relatório costuma ser:
//
//   ~40%  chaves e indentação (PSR-12 puro)          → seguro, aplique
//   ~25%  imports desordenados e não usados          → seguro
//   ~15%  array() → []                               → seguro
//   ~15%  declare_strict_types acrescentado          → RISKY: leia abaixo
//    ~5%  == → ===                                   → RISKY: leia abaixo
//
// declare_strict_types NÃO é cosmético. Com ele ligado, uma função que
// recebe int e é chamada com "42" passa a lançar TypeError em vez de
// converter em silêncio. É o que você quer — mas descubra isso pelos
// testes, não pelo cliente.
//
// A ordem que funciona em projeto legado:
//   1. rodar SEM as regras risky, commitar só formatação
//   2. garantir a suíte verde
//   3. ligar as risky em outro commit, separado
//
// Formatação e mudança de comportamento em commits diferentes: quando algo
// quebrar, o `git bisect` aponta o commit certo em vez de um diff de 400
// arquivos.

// ============ integração com o editor e com o git ============
// .git/hooks/pre-commit  (ou via captainhook/husky)
//
//   #!/bin/sh
//   ARQUIVOS=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')
//   [ -z "$ARQUIVOS" ] && exit 0
//   vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php $ARQUIVOS
//   echo "$ARQUIVOS" | xargs git add

Exercício 2

Instale phpstan/phpstan e rode no nível 5. Corrija todos os erros. Depois suba para o nível 7 e corrija novamente.

Ver resposta

✓ Resposta: Repare no que de fato resolveu os quatro erros: não foi docblock nem cast, foi trocar o array associativo por uma classe ItemDeCarrinho. Subir de nível é útil justamente porque expõe modelagem frouxa — array<string, mixed> é "confie em mim" escrito em sintaxe de tipo. E o pior atalho é o @var que mente: o PHPStan acredita no que você escreveu, o runtime não, e o erro reaparece em produção sem nenhum aviso.

<?php

// ============ phpstan.neon ============
// includes:
//     - phpstan-baseline.neon
// parameters:
//     level: 7
//     paths:
//         - src
//     treatPhpDocTypesAsCertain: false
//
// treatPhpDocTypesAsCertain: false é importante em projeto de verdade —
// docblock é promessa humana, não garantia do runtime. Com ele em true, o
// PHPStan chama de "sempre verdadeira" uma checagem que existe justamente
// porque o docblock às vezes mente.

// ---------- NÍVEL 5: passa. NÍVEL 7: quatro erros ------------------------
declare(strict_types=1);

namespace MeuProjeto\Carrinho;

final class CarrinhoNivel5
{
    /** @var array<string, mixed> */
    private array $itens = [];

    public function adicionar(string $sku, int $quantidade): void
    {
        $this->itens[$sku] = ['quantidade' => $quantidade];
    }

    public function quantidade(string $sku): int
    {
        // (1) nível 7: "cannot access offset 'quantidade' on mixed"
        //     array<string, mixed> promete a chave, não o valor.
        return $this->itens[$sku]['quantidade'];
    }

    public function primeiro(): ?string
    {
        $chaves = array_keys($this->itens);

        // (2) nível 7: "offset 0 might not exist" — array vazio não tem [0]
        return $chaves[0];
    }

    public function buscar(string $sku): ?array
    {
        return $this->itens[$sku] ?? null;
    }

    public function descricao(string $sku): string
    {
        // (3) nível 7: "cannot access offset on array|null" — o método
        //     declara ?array e ninguém tratou o null
        return $this->buscar($sku)['descricao'];
    }

    public function total(array $precos): float
    {
        $total = 0.0;

        foreach ($this->itens as $sku => $item) {
            // (4) nível 7: multiplicação entre mixed e mixed
            $total += $precos[$sku] * $item['quantidade'];
        }

        return $total;
    }
}

// ---------- DEPOIS: nível 7 limpo, sem um único ignoreError --------------
declare(strict_types=1);

namespace MeuProjeto\Carrinho;

use InvalidArgumentException;

/**
 * A correção real não foi acrescentar cast: foi trocar array associativo por
 * um TIPO. O `ItemDeCarrinho` diz o que existe dentro, e os quatro erros
 * somem de uma vez — junto com a classe inteira de bugs que eles anunciavam.
 */
final class ItemDeCarrinho
{
    public function __construct(
        public readonly string $sku,
        public readonly string $descricao,
        public readonly int $quantidade,
    ) {
        if ($quantidade < 1) {
            throw new InvalidArgumentException('Quantidade mínima é 1.');
        }
    }

    public function comQuantidade(int $nova): self
    {
        return new self($this->sku, $this->descricao, $nova);
    }
}

final class CarrinhoNivel7
{
    /** @var array<string, ItemDeCarrinho> */
    private array $itens = [];

    public function adicionar(ItemDeCarrinho $item): void
    {
        // Somar em vez de sobrescrever: adicionar o mesmo SKU duas vezes
        // acumula, que é o que qualquer loja faz.
        $this->itens[$item->sku] = isset($this->itens[$item->sku])
            ? $item->comQuantidade($this->itens[$item->sku]->quantidade + $item->quantidade)
            : $item;
    }

    // (1) resolvido: o tipo do valor é conhecido, e o ?? cobre a chave que
    // não existe — sem ele, sobra um warning de "undefined array key".
    public function quantidade(string $sku): int
    {
        return ($this->itens[$sku] ?? null)?->quantidade ?? 0;
    }

    // (2) resolvido: array_key_first devolve null em array vazio, e o tipo
    // de retorno já diz isso.
    public function primeiroSku(): ?string
    {
        return array_key_first($this->itens);
    }

    public function buscar(string $sku): ?ItemDeCarrinho
    {
        return $this->itens[$sku] ?? null;
    }

    // (3) resolvido: o null é tratado, não ignorado.
    public function descricao(string $sku): string
    {
        return $this->buscar($sku)?->descricao ?? 'Item não encontrado';
    }

    /**
     * (4) resolvido: o docblock diz o tipo do VALOR do array, e o código
     * trata a ausência do preço em vez de multiplicar por mixed.
     *
     * @param array<string, int> $precosEmCentavos
     */
    public function totalEmCentavos(array $precosEmCentavos): int
    {
        $total = 0;

        foreach ($this->itens as $sku => $item) {
            $preco = $precosEmCentavos[$sku]
                ?? throw new InvalidArgumentException("Sem preço para o SKU {$sku}.");

            $total += $preco * $item->quantidade;
        }

        return $total;
    }
}

// ============ o roteiro que funciona ============
//
//   vendor/bin/phpstan analyse src --level=5     # verde
//   vendor/bin/phpstan analyse src --level=6     # aparecem os docblocks
//   vendor/bin/phpstan analyse src --level=7     # aparecem as uniões
//
// Um nível por vez, corrigindo tudo antes de subir. Pular do 0 para o 9
// devolve 3.000 erros, e o resultado previsível é desinstalar a ferramenta.
//
// Em projeto grande, congele o passado e barre só o novo:
//   vendor/bin/phpstan analyse --generate-baseline
//
// E o que NÃO fazer para "resolver" um erro:
//   · /** @phpstan-ignore-next-line */ sem comentário explicando
//   · cast (int) só para calar o analisador — se o valor pode não ser int,
//     o cast esconde o bug em vez de corrigi-lo
//   · @var mentindo o tipo: o PHPStan acredita, o runtime não

Exercício 3

Implemente o container de DI da aula. Adicione suporte a autowiring básico: se a classe tem um construtor com type hints de classes registradas, resolva automaticamente.

Ver resposta

✓ Resposta: Autowiring resolve o que dá para deduzir de um type hint — e só isso. Escalares (string $dsn, int $timeout) e interfaces sem ligação continuam sendo configuração explícita, e o container que tenta adivinhar esses casos é pior que o que falha logo. Note também a guarda contra ciclo: sem ela, uma dependência circular vira estouro de pilha com mensagem ilegível — com ela, o erro nomeia o caminho inteiro A → B → A.

<?php

// ---------------------------------------- src/Container/NaoEncontrado.php
declare(strict_types=1);

namespace MeuProjeto\Container;

use Psr\Container\NotFoundExceptionInterface;
use RuntimeException;

// As interfaces vêm do psr/container: implementar o padrão em vez de
// inventar o seu permite trocar por PHP-DI ou pelo container do Symfony
// sem tocar em quem consome.
final class NaoEncontrado extends RuntimeException implements NotFoundExceptionInterface {}

// ---------------------------------------- src/Container/FalhaAoResolver.php
declare(strict_types=1);

namespace MeuProjeto\Container;

use Psr\Container\ContainerExceptionInterface;
use RuntimeException;

final class FalhaAoResolver extends RuntimeException implements ContainerExceptionInterface {}

// ---------------------------------------- src/Container/Container.php
declare(strict_types=1);

namespace MeuProjeto\Container;

use Closure;
use Psr\Container\ContainerInterface;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionParameter;

final class Container implements ContainerInterface
{
    /** @var array<string, Closure(Container): mixed> */
    private array $fabricas = [];

    /** @var array<string, mixed> instâncias já criadas dos singletons */
    private array $resolvidos = [];

    /** @var array<string, true> */
    private array $singletons = [];

    /** @var array<string, string> interface → implementação concreta */
    private array $ligacoes = [];

    /** @var array<string, true> guarda contra dependência circular */
    private array $resolvendo = [];

    /** @param Closure(Container): mixed $fabrica */
    public function registrar(string $id, Closure $fabrica, bool $singleton = true): void
    {
        $this->fabricas[$id] = $fabrica;

        if ($singleton) {
            $this->singletons[$id] = true;
        }

        // Registrar de novo invalida o que já havia sido criado — senão o
        // container devolveria a instância antiga da fábrica velha.
        unset($this->resolvidos[$id]);
    }

    /** LoggerInterface::class → MonologAdapter::class */
    public function ligar(string $abstracao, string $concreta): void
    {
        $this->ligacoes[$abstracao] = $concreta;
    }

    public function instancia(string $id, mixed $objeto): void
    {
        $this->resolvidos[$id] = $objeto;
        $this->singletons[$id] = true;
    }

    public function has(string $id): bool
    {
        return isset($this->fabricas[$id])
            || isset($this->resolvidos[$id])
            || isset($this->ligacoes[$id])
            || class_exists($id);
    }

    public function get(string $id): mixed
    {
        if (array_key_exists($id, $this->resolvidos)) {
            return $this->resolvidos[$id];
        }

        // Sem esta guarda, A→B→A estoura a pilha e o erro fatal não diz
        // nada útil. Com ela, a mensagem nomeia o ciclo inteiro.
        if (isset($this->resolvendo[$id])) {
            throw new FalhaAoResolver(sprintf(
                'Dependência circular: %s',
                implode(' → ', [...array_keys($this->resolvendo), $id]),
            ));
        }

        $this->resolvendo[$id] = true;

        try {
            $objeto = isset($this->fabricas[$id])
                ? ($this->fabricas[$id])($this)
                : $this->construir($this->ligacoes[$id] ?? $id);
        } finally {
            unset($this->resolvendo[$id]);
        }

        if (isset($this->singletons[$id]) || isset($this->ligacoes[$id])) {
            $this->resolvidos[$id] = $objeto;
        }

        return $objeto;
    }

    /** O autowiring propriamente dito. */
    private function construir(string $classe): object
    {
        if (!class_exists($classe)) {
            throw new NaoEncontrado("Não sei construir \"{$classe}\": classe inexistente.");
        }

        $reflexao = new ReflectionClass($classe);

        if (!$reflexao->isInstantiable()) {
            throw new FalhaAoResolver(sprintf(
                '%s não é instanciável — registre uma ligação para a implementação concreta.',
                $classe,
            ));
        }

        $construtor = $reflexao->getConstructor();

        // Sem construtor (ou sem parâmetros): new e pronto.
        if ($construtor === null || $construtor->getNumberOfParameters() === 0) {
            return new $classe();
        }

        $argumentos = array_map(
            fn (ReflectionParameter $p): mixed => $this->resolverParametro($p, $classe),
            $construtor->getParameters(),
        );

        return $reflexao->newInstanceArgs($argumentos);
    }

    private function resolverParametro(ReflectionParameter $parametro, string $classe): mixed
    {
        $tipo = $parametro->getType();

        // Tipo de classe: resolve recursivamente. É o coração do autowiring.
        if ($tipo instanceof ReflectionNamedType && !$tipo->isBuiltin()) {
            return $this->get($tipo->getName());
        }

        // Escalar (string $dsn, int $timeout): o container não tem como
        // adivinhar. Se houver padrão no construtor, use-o.
        if ($parametro->isDefaultValueAvailable()) {
            return $parametro->getDefaultValue();
        }

        if ($tipo?->allowsNull() === true) {
            return null;
        }

        // A mensagem diz exatamente o que fazer — container que falha com
        // "erro ao resolver" e nada mais é pior que não ter container.
        throw new FalhaAoResolver(sprintf(
            'Não consigo resolver $%s de %s: tipo %s não é classe e não tem valor padrão. '
            . 'Registre uma fábrica com $container->registrar(%s::class, ...).',
            $parametro->getName(),
            $classe,
            $tipo instanceof ReflectionNamedType ? $tipo->getName() : 'indefinido',
            $classe,
        ));
    }
}

// ---------------------------------------- config/container.php
declare(strict_types=1);

use MeuProjeto\Container\Container;
use MeuProjeto\Dominio\PedidoRepositorio;
use MeuProjeto\Infra\PedidoRepositorioPdo;
use MeuProjeto\Suporte\Ambiente;
use Psr\Log\LoggerInterface;

$container = new Container();

// 1. O que o autowiring NÃO adivinha: tudo que precisa de configuração.
$container->registrar(PDO::class, static function (): PDO {
    return new PDO(
        sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4',
            Ambiente::texto('DB_HOST'), Ambiente::texto('DB_NAME')),
        Ambiente::texto('DB_USER'),
        Ambiente::texto('DB_PASS', ''),
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
    );
});

$container->registrar(
    LoggerInterface::class,
    static fn (): LoggerInterface => MeuProjeto\Infra\Log\Fabrica::criar('app'),
);

// 2. Interface → implementação. Trocar de banco é trocar esta linha.
$container->ligar(PedidoRepositorio::class, PedidoRepositorioPdo::class);

// 3. Todo o resto — controllers, casos de uso, serviços — o container monta
// sozinho, lendo os type hints do construtor. Nada a registrar.
return $container;

// ---------------------------------------- tests/ContainerTest.php
declare(strict_types=1);

namespace MeuProjeto\Tests\Container;

use MeuProjeto\Container\Container;
use MeuProjeto\Container\FalhaAoResolver;
use PHPUnit\Framework\TestCase;

final class Servico
{
    public function __construct(public readonly Dependencia $dependencia) {}
}

final class Dependencia
{
    public function __construct(public readonly string $nome = 'padrão') {}
}

final class ContainerTest extends TestCase
{
    public function testAutowiringMontaAArvoreInteira(): void
    {
        $servico = (new Container())->get(Servico::class);

        $this->assertInstanceOf(Servico::class, $servico);
        // Nada foi registrado: as duas classes vieram só do type hint.
        $this->assertSame('padrão', $servico->dependencia->nome);
    }

    public function testSingletonDevolveSempreAMesmaInstancia(): void
    {
        $container = new Container();

        $this->assertSame($container->get(Dependencia::class), $container->get(Dependencia::class));
    }

    public function testFabricaExplicitaVenceOAutowiring(): void
    {
        $container = new Container();
        $container->registrar(
            Dependencia::class,
            static fn (): Dependencia => new Dependencia('configurada'),
        );

        $this->assertSame('configurada', $container->get(Servico::class)->dependencia->nome);
    }

    public function testEscalarSemPadraoDaMensagemUtil(): void
    {
        $container = new Container();

        $this->expectException(FalhaAoResolver::class);
        $this->expectExceptionMessageMatches('/Registre uma fábrica/');

        $container->get(PrecisaDeString::class);
    }
}

final class PrecisaDeString
{
    public function __construct(public readonly string $dsn) {}
}

Exercício 4

Refatore uma classe que usa error_log() diretamente para receber um LoggerInterface por injeção de dependência. Use Monolog como implementação concreta, injetado pelo container.

Ver resposta

✓ Resposta: O ganho que se sente na primeira madrugada de incidente: passar a exceção inteira no contexto sob a chave 'exception', e não $e->getMessage() — só assim o stack trace chega ao log. E a escolha de nível é decisão de operação, não de estilo: cartão recusado é warning (fluxo de negócio), gateway fora do ar é error. Trocar os dois de lugar é como o alerta do time morre — dispara toda hora, ninguém mais olha.

<?php

// ---------- ANTES: o log grudado na classe ------------------------------
declare(strict_types=1);

namespace MeuProjeto\Pagamento;

final class ProcessadorAntes
{
    public function cobrar(int $pedidoId, int $centavos): bool
    {
        // Três problemas num só lugar:
        // 1. destino fixo — não dá para mandar para stderr, Sentry ou fila
        // 2. sem nível — tudo vira a mesma linha, tanto info quanto erro
        // 3. INTESTÁVEL — o teste não tem como afirmar que "logou"; no
        //    máximo ele suja o error_log da máquina de CI
        error_log("Cobrando pedido {$pedidoId}: {$centavos} centavos");

        try {
            $resposta = $this->gateway($pedidoId, $centavos);
        } catch (\Throwable $e) {
            error_log('Erro no gateway: ' . $e->getMessage());   // stack trace perdido

            return false;
        }

        if (!$resposta['aprovado']) {
            error_log("Recusado: {$resposta['motivo']}");

            return false;
        }

        error_log("Pedido {$pedidoId} pago");

        return true;
    }

    /** @return array{aprovado: bool, motivo: string} */
    private function gateway(int $pedidoId, int $centavos): array
    {
        return ['aprovado' => true, 'motivo' => ''];
    }
}

// ---------- DEPOIS: dependência declarada no construtor ------------------
declare(strict_types=1);

namespace MeuProjeto\Pagamento;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Throwable;

final class Processador
{
    public function __construct(
        private readonly Gateway $gateway,
        // O NullLogger como padrão é a cortesia que a PSR-3 previu: quem só
        // quer usar a classe não é obrigado a montar um logger, e o código
        // interno nunca precisa de `if ($this->logger !== null)`.
        private readonly LoggerInterface $logger = new NullLogger(),
    ) {}

    public function cobrar(int $pedidoId, int $centavos): bool
    {
        // Mensagem com {placeholder} e contexto em array: o texto fica
        // estável (dá para agrupar 10 mil ocorrências da MESMA mensagem) e
        // os valores viram campos consultáveis no JSON.
        $contexto = ['pedido_id' => $pedidoId, 'centavos' => $centavos];

        $this->logger->info('Iniciando cobrança do pedido {pedido_id}', $contexto);

        try {
            $resposta = $this->gateway->cobrar($pedidoId, $centavos);
        } catch (Throwable $e) {
            // A exceção INTEIRA no contexto, sob a chave 'exception': é a
            // convenção da PSR-3, e o Monolog extrai daí o stack trace.
            // Passar só getMessage() joga fora a única informação que
            // resolveria o incidente às 3h da manhã.
            $this->logger->error('Gateway indisponível para o pedido {pedido_id}', [
                ...$contexto,
                'exception' => $e,
            ]);

            return false;
        }

        if (!$resposta->aprovado) {
            // warning, não error: recusa de cartão é fluxo normal do
            // negócio. Se virar `error`, o alerta do time toca a cada
            // limite estourado e ninguém mais olha para o painel.
            $this->logger->warning('Pagamento recusado: {motivo}', [
                ...$contexto,
                'motivo' => $resposta->motivo,
            ]);

            return false;
        }

        $this->logger->info('Pedido {pedido_id} pago', [
            ...$contexto,
            'autorizacao' => $resposta->autorizacao,
        ]);

        return true;
    }
}

// ---------------------------------------- src/Pagamento/Gateway.php
declare(strict_types=1);

namespace MeuProjeto\Pagamento;

interface Gateway
{
    public function cobrar(int $pedidoId, int $centavos): RespostaDoGateway;
}

// ---------------------------------------- src/Pagamento/RespostaDoGateway.php
declare(strict_types=1);

namespace MeuProjeto\Pagamento;

final class RespostaDoGateway
{
    public function __construct(
        public readonly bool $aprovado,
        public readonly string $motivo = '',
        public readonly string $autorizacao = '',
    ) {}
}

// ---------------------------------------- config/container.php (trecho)
declare(strict_types=1);

use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
use Psr\Log\LoggerInterface;

// O container liga a interface à implementação concreta uma única vez.
// Nenhuma classe do domínio menciona Monolog — trocar para outro logger
// PSR-3 é editar este bloco.
$container->registrar(LoggerInterface::class, static function (): LoggerInterface {
    $logger = new Logger('pagamentos');

    $logger->pushHandler(new RotatingFileHandler(
        filename: __DIR__ . '/../storage/logs/pagamentos.log',
        maxFiles: 30,
        level: Level::Info,
    ));

    $logger->pushHandler(new StreamHandler('php://stderr', Level::Error));
    $logger->pushProcessor(new PsrLogMessageProcessor());

    return $logger;
});

// O Processador nem precisa ser registrado: o autowiring lê o construtor,
// vê Gateway e LoggerInterface, e resolve os dois pelo container.

// ---------------------------------------- tests/ProcessadorTest.php
declare(strict_types=1);

namespace MeuProjeto\Tests\Pagamento;

use MeuProjeto\Pagamento\Gateway;
use MeuProjeto\Pagamento\Processador;
use MeuProjeto\Pagamento\RespostaDoGateway;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use RuntimeException;

/** Logger de teste: guarda em memória o que seria registrado. */
final class LoggerEspiao extends AbstractLogger
{
    /** @var list<array{nivel: string, mensagem: string, contexto: array<string, mixed>}> */
    public array $registros = [];

    public function log($level, $message, array $context = []): void
    {
        $this->registros[] = [
            'nivel'    => (string) $level,
            'mensagem' => (string) $message,
            'contexto' => $context,
        ];
    }

    /** @return list<array{nivel: string, mensagem: string, contexto: array<string, mixed>}> */
    public function doNivel(string $nivel): array
    {
        return array_values(array_filter(
            $this->registros,
            static fn (array $r): bool => $r['nivel'] === $nivel,
        ));
    }
}

final class ProcessadorTest extends TestCase
{
    public function testRecusaEhWarningComOMotivo(): void
    {
        $espiao = new LoggerEspiao();

        $gateway = new class implements Gateway {
            public function cobrar(int $pedidoId, int $centavos): RespostaDoGateway
            {
                return new RespostaDoGateway(false, 'limite insuficiente');
            }
        };

        $this->assertFalse((new Processador($gateway, $espiao))->cobrar(42, 9990));

        $avisos = $espiao->doNivel('warning');

        // Isto é o que era IMPOSSÍVEL testar com error_log().
        $this->assertCount(1, $avisos);
        $this->assertSame('limite insuficiente', $avisos[0]['contexto']['motivo']);
        $this->assertSame(42, $avisos[0]['contexto']['pedido_id']);
    }

    public function testFalhaDoGatewayViraErrorComAExcecaoNoContexto(): void
    {
        $espiao = new LoggerEspiao();

        $gateway = new class implements Gateway {
            public function cobrar(int $pedidoId, int $centavos): RespostaDoGateway
            {
                throw new RuntimeException('timeout');
            }
        };

        $this->assertFalse((new Processador($gateway, $espiao))->cobrar(7, 100));

        $erros = $espiao->doNivel('error');

        $this->assertCount(1, $erros);
        // A exceção viaja inteira: é dela que sai o stack trace no log.
        $this->assertInstanceOf(RuntimeException::class, $erros[0]['contexto']['exception']);
    }
}

Exercício 5

Desafio: crie um pipeline de CI simples com GitHub Actions (ou script Makefile) que rode php-cs-fixer --dry-run, phpstan analyse, e phpunit em sequência — e falhe se qualquer um deles falhar.

Ver resposta

✓ Resposta: Duas coisas fazem o pipeline valer o esforço. A ordem — estilo e análise levam segundos, testes levam minutos, então falhe barato primeiro — e o --dry-run no fixer: CI que corrige e faz commit sozinho gera conflito no PR alheio. E o portão de verdade não está no YAML: sem marcar "Require status checks" na proteção do branch, o X vermelho é decorativo e o botão de merge continua clicável.

# ============ .github/workflows/qualidade.yml ============
name: Qualidade

on:
  push:
    branches: [main]
  pull_request:

# Cancela execuções antigas do mesmo PR: sem isto, três pushes seguidos
# ocupam três runners para dar o mesmo resultado.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  qualidade:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: senha-de-teste
          MYSQL_DATABASE: app_teste
        ports: ['3306:3306']
        # Sem o healthcheck, o job começa antes do banco aceitar conexão e
        # o primeiro teste falha por corrida, não por bug.
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Instalar PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql, intl
          coverage: pcov
          # O fixer e o phpstan ficam FORA do composer.json do projeto:
          # assim a versão de análise não conflita com as dependências.
          tools: php-cs-fixer, phpstan

      - name: Cache do Composer
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: composer-

      - name: Instalar dependências
        # --no-progress deixa o log legível; --prefer-dist é mais rápido que
        # clonar cada pacote.
        run: composer install --no-interaction --no-progress --prefer-dist

      # ---- os três portões. Cada um falha o job por conta própria ----

      - name: Formatação (PSR-12)
        # --dry-run: a CI APONTA, não corrige. Commit automático em PR de
        # terceiro é caminho curto para conflito e para surpresa.
        run: php-cs-fixer fix --dry-run --diff --verbose

      - name: Análise estática
        run: phpstan analyse --error-format=github --no-progress

      - name: Testes
        env:
          DB_HOST: 127.0.0.1
          DB_NAME: app_teste
          DB_USER: root
          DB_PASS: senha-de-teste
        run: vendor/bin/phpunit --colors=always --coverage-text

      # `if: always()` faz o relatório subir mesmo com teste vermelho —
      # que é exatamente quando alguém quer olhar o relatório.
      - name: Guardar relatório
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: relatorio-testes
          path: build/
          retention-days: 7

# O passo que fecha o portão de verdade está fora do YAML:
# Settings → Branches → Add rule → main
#   ☑ Require status checks to pass before merging  →  "qualidade"
#   ☑ Require branches to be up to date before merging
# Sem isso, a CI vermelha é só um X vermelho decorativo ao lado do botão
# de merge, que continua clicável.

# ============ Makefile — o MESMO pipeline na sua máquina ============
#
# Rodar localmente o que a CI roda é o que evita o ciclo "commit, espera
# quatro minutos, vermelho, corrige, commit".
#
# .PHONY: qualidade estilo estilo-fix analise teste
#
# qualidade: estilo analise teste   ## tudo, na ordem da CI
#
# estilo:                            ## checa a formatação (não altera nada)
# 	vendor/bin/php-cs-fixer fix --dry-run --diff
#
# estilo-fix:                        ## aplica a formatação
# 	vendor/bin/php-cs-fixer fix
#
# analise:
# 	vendor/bin/phpstan analyse --no-progress
#
# teste:
# 	vendor/bin/phpunit --colors=always
#
# Detalhe do make que derruba todo mundo: a indentação de cada receita é
# TAB de verdade, nunca espaços — com espaços a mensagem é o críptico
# "missing separator".
#
# O make encadeia com &&: o primeiro alvo que falhar interrompe a corrente,
# que é o comportamento desejado. E a ordem é deliberada — estilo e análise
# levam segundos, os testes levam minutos; falhe barato primeiro.
Comentários

Mais em PHP

Testes Automatizados com PHPUnit
Testes Automatizados com PHPUnit

Do teste unitário de uma entidade ao de integração com SQLite em memória…

SQLite com PHP
SQLite com PHP

Um arquivo, zero configuração e uma busca textual com FTS5 que dispensa…

Projeto Final Parte 1: Planejamento e Arquitetura
Projeto Final Parte 1: Planejamento e Arquitetura

Escopo, decisões de arquitetura com justificativa, o schema completo em…