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
- PHP-FIG (todas as PSRs): https://www.php-fig.org/psr/
- PHP CS Fixer: https://cs.symfony.com/
- PHPStan: https://phpstan.org/
- Packagist — PSR interfaces: https://packagist.org/packages/psr/log
- PHP-FIG — PSR-12, o estilo de codigo que substituiu a PSR-2: https://www.php-fig.org/psr/psr-12/
- PHP_CodeSniffer — verifica e corrige o estilo com
phpcsephpcbf: https://github.com/PHPCSStandards/PHP_CodeSniffer
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.