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

A História do PHP: de script pessoal a pilar da web
A História do PHP: de script pessoal a pilar da web

De um script para contar visitas a um currículo até a linguagem que move boa…

Composer e Namespaces
Composer e Namespaces

Namespaces que evitam colisão de nomes, PSR-4 que dispensa o require manual e…

Interfaces Avançadas
Interfaces Avançadas

Interface é contrato, e contrato tem regras que vão além de listar métodos…