Antes do Composer, gerenciar dependências em PHP era um pesadelo manual: baixar arquivos, copiá-los para o projeto, atualizar à mão, rezar para que as versões fossem compatíveis. Em 2012, o Composer mudou tudo. Hoje é impossível trabalhar profissionalmente com PHP sem ele.
Namespaces, introduzidos no PHP 5.3, resolvem um problema diferente mas relacionado: colisão de nomes. Sem namespaces, duas bibliotecas que definem uma classe Router causam conflito. Com namespaces, Symfony\Component\Routing\Router e League\Route\Router coexistem sem problema.
Namespaces — organizando o código
Um namespace é como um pacote ou diretório lógico para suas classes. Declare no topo do arquivo, antes de qualquer código:
<?php
declare(strict_types=1);
namespace App\Domain\Usuario;
// Esta classe vive em App\Domain\Usuario\Usuario
class Usuario
{
public function __construct(
public readonly int $id,
public readonly string $nome,
public readonly string $email,
) {}
}
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence;
use App\Domain\Usuario\Usuario; // importa a classe
use App\Domain\Usuario\UsuarioRepositorioInterface; // importa interface
use PDO; // classe global (sem namespace)
class UsuarioRepositorioPDO implements UsuarioRepositorioInterface
{
public function __construct(private readonly PDO $pdo) {}
public function buscarPorId(int $id): ?Usuario
{
$stmt = $this->pdo->prepare('SELECT * FROM usuarios WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) return null;
return new Usuario($row['id'], $row['nome'], $row['email']);
}
}
Alias — quando dois namespaces têm o mesmo nome de classe:
<?php
use App\Domain\Pedido\Status;
use App\Domain\Pagamento\Status as StatusPagamento; // alias
$status = new Status('pendente');
$statusPagamento = new StatusPagamento('aguardando');
PSR-4 — Autoloading padronizado
PSR-4 é o padrão que mapeia namespaces para diretórios. Com ele, o PHP sabe onde encontrar cada classe sem que você precise fazer require manualmente:
src/
├── Domain/
│ └── Usuario/
│ ├── Usuario.php → App\Domain\Usuario\Usuario
│ └── UsuarioRepositorioInterface.php → App\Domain\Usuario\UsuarioRepositorioInterface
└── Infrastructure/
└── Persistence/
└── UsuarioRepositorioPDO.php → App\Infrastructure\Persistence\UsuarioRepositorioPDO
Composer — gerenciamento de dependências
O arquivo composer.json é o coração de qualquer projeto PHP moderno. Ele define o projeto, suas dependências e o mapeamento PSR-4:
{
"name": "empresa/meu-projeto",
"description": "Sistema de gestão de pedidos",
"type": "project",
"require": {
"php": "^8.2",
"monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.10"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}
Comandos essenciais:
# Instalar todas as dependências do composer.json
composer install
# Adicionar uma nova dependência
composer require guzzlehttp/guzzle
# Adicionar dependência apenas para desenvolvimento
composer require --dev phpunit/phpunit
# Atualizar todas as dependências para as versões mais recentes permitidas
composer update
# Atualizar apenas uma dependência
composer update monolog/monolog
# Regenerar o autoloader após adicionar classes manualmente
composer dump-autoload
# Verificar se há vulnerabilidades de segurança
composer audit
Versionamento semântico
O Composer usa semver (Semantic Versioning): MAJOR.MINOR.PATCH.
{
"require": {
"vendor/pacote": "^2.1.0"
}
}
| Operador | Significado | Exemplo |
|---|---|---|
^2.1.0 |
>= 2.1.0 e < 3.0.0 |
Aceita atualizações minor e patch |
~2.1.0 |
>= 2.1.0 e < 2.2.0 |
Aceita apenas atualizações patch |
2.1.0 |
Exatamente 2.1.0 |
Sem flexibilidade |
* |
Qualquer versão | Evite em produção |
>=2.0 <3.0 |
Intervalo explícito | Equivalente a ^2.0 |
^0.3.0 |
>= 0.3.0 e < 0.4.0 |
Antes do 1.0, o ^ trava também o minor |
Essa última linha é a que mais surpreende. O acento circunflexo costuma ser descrito como "aceita minor e patch", mas isso vale a partir da versão 1.0. Abaixo dela, o Composer trata o minor como se fosse o major, porque em projetos 0.x é ali que as mudanças incompatíveis acontecem — então ^0.3 não sobe para 0.4. Quem depende de biblioteca ainda na faixa zero recebe bem menos atualização automática do que imagina.
O composer.lock registra as versões exatas instaladas. Sempre commite o composer.lock em projetos de aplicação — garante que todos os membros da equipe (e o servidor de produção) usem exatamente as mesmas versões. Em bibliotecas a regra é menos categórica do que se costuma repetir: a própria documentação do Composer diz que você pode commitar o composer.lock se quiser. O que ela garante é que esse arquivo não terá efeito nenhum sobre os projetos que dependem da sua biblioteca — quem instala resolve as versões pelo próprio composer.json. Commitar ajuda a deixar a integração contínua da biblioteca reproduzível; não commitar faz o CI testar sempre contra as versões mais recentes permitidas, o que descobre incompatibilidade mais cedo. São dois objetivos diferentes, e vale escolher conscientemente.
Usando as dependências
<?php
declare(strict_types=1);
// O autoloader do Composer — inclua uma única vez no bootstrap da aplicação
require __DIR__ . '/vendor/autoload.php';
use Monolog\Logger;
use Monolog\Level; // Monolog 3: o nível virou enum
use Monolog\Handler\StreamHandler;
use Dotenv\Dotenv;
// Carrega variáveis de ambiente do arquivo .env
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Cria um logger
$log = new Logger('app');
// O composer.json acima pede monolog/monolog ^3.0, e nessa versão as
// constantes Logger::DEBUG, Logger::WARNING e companhia estão marcadas como
// deprecated — o substituto é o enum Monolog\Level. Quando precisar do
// inteiro que a constante antiga devolvia, use Level::Debug->value.
$log->pushHandler(new StreamHandler(__DIR__ . '/storage/logs/app.log', Level::Debug));
$log->info('Aplicação iniciada', [
'ambiente' => $_ENV['APP_ENV'] ?? 'production',
'versao' => '1.0.0',
]);
// Acessa variável de ambiente definida no .env
$dsn = $_ENV['DATABASE_URL'];
Criando sua própria biblioteca com Composer
{
"name": "ricardomatosdev/validador",
"description": "Biblioteca de validação para PHP",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Ricardo Matos",
"email": "contato@ricardomatosdev.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"RicardoMatos\\Validador\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"RicardoMatos\\Validador\\Tests\\": "tests/"
}
}
}
Namespace e autoloading resolvem o mesmo problema em dois tempos: o primeiro dá nome completo a cada classe, o segundo ensina o PHP a encontrar o arquivo correspondente. Depois que o PSR-4 está no lugar, require manual vira coisa do passado — e o preço é uma disciplina só: o caminho do arquivo tem de espelhar o namespace, letra por letra, inclusive nas maiúsculas, porque em Linux o sistema de arquivos não perdoa o que o seu notebook perdoava.
Do Composer, o que mais rende aprender não é o require, é ler a recusa. Quando a resolução falha, a última linha de cada bloco diz exatamente quais dois intervalos não se cruzam, e a partir daí a decisão é sua: afrouxar o próprio requisito, baixar a versão de quem trouxe a exigência, ou parar de fixar versão onde não era preciso. Contornar com flags de força bruta apenas adia o mesmo conflito para o dia do deploy.
Fontes e leituras recomendadas
- Documentação oficial do Composer: https://getcomposer.org/doc/
- PSR-4 Autoloading Standard: https://www.php-fig.org/psr/psr-4/
- Packagist (repositório de pacotes PHP): https://packagist.org/
- Semantic Versioning: https://semver.org/lang/pt-BR/
- Composer — uso basico, com a diferenca entre
installeupdatee o papel docomposer.lock: https://getcomposer.org/doc/01-basic-usage.md - Composer — otimizacao do autoloader para producao (
--classmap-authoritative): https://getcomposer.org/doc/articles/autoloader-optimization.md
Exercícios
Exercício 1
Crie um projeto do zero com composer init. Configure o PSR-4 para o namespace MeuProjeto\\ apontando para src/. Crie três classes em subnamespaces diferentes e instancie-as sem nenhum require manual.
Ver resposta
✓ Resposta: O erro que consome a primeira tarde inteira é declarar namespace MeuProjeto\src\Dominio: a pasta src/ já foi consumida pelo prefixo do PSR-4 e não aparece no namespace. O diagnóstico rápido é composer dump-autoload --strict-psr, que lista toda classe cujo arquivo não corresponde ao nome. E lembre que files carrega em todo request — é conveniente para meia dúzia de funções e um peso morto para qualquer coisa além disso.
<?php
// ============ no terminal ============
// mkdir meu-projeto && cd meu-projeto
// composer init --no-interaction --name="voce/meu-projeto" --type=project
// composer dump-autoload
// ============ composer.json ============
// {
// "name": "voce/meu-projeto",
// "type": "project",
// "require": { "php": ">=8.2" },
// "autoload": {
// "psr-4": { "MeuProjeto\\": "src/" },
// "files": [ "src/helpers.php" ]
// },
// "autoload-dev": {
// "psr-4": { "MeuProjeto\\Tests\\": "tests/" }
// },
// "config": { "sort-packages": true }
// }
//
// A regra do PSR-4 cabe numa frase: o PREFIXO do namespace aponta para uma
// PASTA, e o resto do nome vira caminho.
//
// MeuProjeto\Dominio\Pedido → src/Dominio/Pedido.php
// MeuProjeto\Infra\Log\Ecoa → src/Infra/Log/Ecoa.php
//
// Não existe "src" no meio do namespace: a pasta já foi consumida pelo
// prefixo. Esse é o erro nº 1 de quem começa — declarar
// `namespace MeuProjeto\src\Dominio` e passar a tarde procurando o autoload.
//
// As três estratégias, e quando cada uma serve:
//
// psr-4 → classes do seu código. Carrega sob demanda, é o padrão.
// classmap → código legado sem namespace ou fora do padrão de pastas.
// O Composer varre e monta um mapa fixo; exige dump-autoload
// a cada arquivo novo.
// files → o único que carrega SEMPRE, em todo request. É onde vão
// funções soltas — e por isso a lista tem de ser curta.
// ---------------------------------------- src/Suporte/Dinheiro.php
declare(strict_types=1);
namespace MeuProjeto\Suporte;
use InvalidArgumentException;
final class Dinheiro
{
// Centavos como inteiro. float para dinheiro é como medir terreno com
// régua de borracha: 0.1 + 0.2 não dá 0.3 em ponto flutuante binário.
public function __construct(public readonly int $centavos)
{
if ($centavos < 0) {
throw new InvalidArgumentException('Valor negativo não é dinheiro.');
}
}
public static function deReais(string $valor): self
{
return new self((int) round((float) str_replace(',', '.', $valor) * 100));
}
public function somar(self $outro): self
{
return new self($this->centavos + $outro->centavos);
}
public function __toString(): string
{
return 'R$ ' . number_format($this->centavos / 100, 2, ',', '.');
}
}
// ---------------------------------------- src/Dominio/Pedido.php
declare(strict_types=1);
namespace MeuProjeto\Dominio;
use DateTimeImmutable;
use MeuProjeto\Suporte\Dinheiro;
final class Pedido
{
/** @var list<array{descricao: string, valor: Dinheiro}> */
private array $itens = [];
public function __construct(
public readonly string $codigo,
public readonly DateTimeImmutable $abertoEm = new DateTimeImmutable(),
) {}
public function adicionar(string $descricao, Dinheiro $valor): void
{
$this->itens[] = ['descricao' => $descricao, 'valor' => $valor];
}
public function total(): Dinheiro
{
return array_reduce(
$this->itens,
static fn (Dinheiro $soma, array $item): Dinheiro => $soma->somar($item['valor']),
new Dinheiro(0),
);
}
}
// ---------------------------------------- src/Infra/Log/Ecoa.php
declare(strict_types=1);
namespace MeuProjeto\Infra\Log;
final class Ecoa
{
public function __construct(private readonly string $canal = 'app') {}
public function registrar(string $mensagem): void
{
printf("[%s] %s %s\n", $this->canal,
(new \DateTimeImmutable())->format('H:i:s'), $mensagem);
}
}
// ---------------------------------------- src/helpers.php
declare(strict_types=1);
// Sem namespace de propósito: função global é para ser curta e onipresente.
// Só que "carregado em todo request" é uma conta que se paga sempre —
// mantenha este arquivo com meia dúzia de funções, no máximo.
if (!function_exists('e')) {
/** Escape de saída HTML. O nome curto é o ponto: será usado centenas de vezes. */
function e(?string $texto): string
{
return htmlspecialchars($texto ?? '', ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('caminho_base')) {
function caminho_base(string $relativo = ''): string
{
return dirname(__DIR__) . ($relativo === '' ? '' : '/' . ltrim($relativo, '/'));
}
}
// ---------------------------------------- public/index.php
declare(strict_types=1);
// A ÚNICA linha de require do projeto inteiro. Depois dela, toda classe
// aparece sozinha — inclusive as que você criar amanhã.
require __DIR__ . '/../vendor/autoload.php';
use MeuProjeto\Dominio\Pedido;
use MeuProjeto\Infra\Log\Ecoa;
use MeuProjeto\Suporte\Dinheiro;
$log = new Ecoa('pedidos');
$pedido = new Pedido('PED-2026-0001');
$pedido->adicionar('Teclado mecânico', Dinheiro::deReais('349,90'));
$pedido->adicionar('Mousepad', Dinheiro::deReais('79,00'));
$log->registrar("Pedido {$pedido->codigo} — total {$pedido->total()}");
// e() veio do autoload "files", sem nenhum require:
echo '<p>', e('Total: ' . $pedido->total()), '</p>';
// ============ quando o autoload "não funciona" ============
// composer dump-autoload -o regenera o mapa (obrigatório após mexer no
// classmap; opcional no psr-4)
// composer dump-autoload -o --strict-psr
// aponta toda classe cujo arquivo não bate com
// o namespace — o diagnóstico que resolve 90%
// dos "class not found"
Exercício 2
Adicione as dependências monolog/monolog e vlucas/phpdotenv. Configure um logger que escreve em arquivo e um arquivo .env com as configurações do banco de dados. Leia as variáveis no código.
Ver resposta
✓ Resposta: Duas armadilhas específicas destes dois pacotes. No phpdotenv 5, o .env popula $_ENV e $_SERVER, mas não getenv() — quem vem da v3 passa horas nisso. E todo valor lido é string: APP_DEBUG=false é a string "false", que em PHP é verdadeira. Sem FILTER_VALIDATE_BOOL, o debug fica ligado em produção exibindo stack trace para o mundo.
<?php
// ============ no terminal ============
// composer require monolog/monolog vlucas/phpdotenv
// ============ .env (NUNCA versionado — coloque no .gitignore) ============
// APP_ENV=local
// APP_DEBUG=true
// DB_HOST=127.0.0.1
// DB_PORT=3306
// DB_NAME=loja
// DB_USER=root
// DB_PASS=troque-aqui
// LOG_NIVEL=debug
// ============ .env.example (versionado, sem valor real) ============
// APP_ENV=producao
// APP_DEBUG=false
// DB_HOST=
// DB_PORT=3306
// DB_NAME=
// DB_USER=
// DB_PASS=
// LOG_NIVEL=warning
// ---------------------------------------- src/Suporte/Ambiente.php
declare(strict_types=1);
namespace MeuProjeto\Suporte;
use Dotenv\Dotenv;
use RuntimeException;
final class Ambiente
{
private static bool $carregado = false;
public static function carregar(string $raiz): void
{
if (self::$carregado) {
return;
}
// safeLoad() não explode se o .env não existir — em produção as
// variáveis costumam vir do próprio ambiente (systemd, Docker, CI),
// e ali o arquivo legitimamente não existe.
$dotenv = Dotenv::createImmutable($raiz);
$dotenv->safeLoad();
// Isto sim tem de explodir: melhor morrer no boot com mensagem clara
// do que conectar no banco errado e descobrir três horas depois.
$dotenv->required(['APP_ENV', 'DB_HOST', 'DB_NAME', 'DB_USER'])->notEmpty();
$dotenv->required('APP_ENV')->allowedValues(['local', 'teste', 'producao']);
$dotenv->required('DB_PORT')->isInteger();
self::$carregado = true;
}
public static function texto(string $chave, ?string $padrao = null): string
{
// O phpdotenv v5 popula $_ENV e $_SERVER, mas NÃO getenv() — por
// padrão ele não escreve no ambiente do processo. Ler por getenv()
// e receber false é a dúvida clássica de quem vem da v3.
$valor = $_ENV[$chave] ?? $padrao;
if ($valor === null) {
throw new RuntimeException("Variável de ambiente ausente: {$chave}");
}
return (string) $valor;
}
public static function booleano(string $chave, bool $padrao = false): bool
{
// "false", "0" e "" vindos do .env são STRING — e toda string não
// vazia é true em PHP. Sem esta conversão, APP_DEBUG=false liga o
// debug em produção.
return filter_var(
$_ENV[$chave] ?? $padrao,
FILTER_VALIDATE_BOOL,
FILTER_NULL_ON_FAILURE,
) ?? $padrao;
}
public static function inteiro(string $chave, int $padrao): int
{
return isset($_ENV[$chave]) ? (int) $_ENV[$chave] : $padrao;
}
}
// ---------------------------------------- src/Infra/Log/Fabrica.php
declare(strict_types=1);
namespace MeuProjeto\Infra\Log;
use Monolog\Formatter\JsonFormatter;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Processor\PsrLogMessageProcessor;
use MeuProjeto\Suporte\Ambiente;
use Psr\Log\LoggerInterface;
final class Fabrica
{
public static function criar(string $canal = 'app'): LoggerInterface
{
$logger = new Logger($canal);
$nivel = Level::fromName(ucfirst(Ambiente::texto('LOG_NIVEL', 'debug')));
// Handler 1 — arquivo com rotação diária, 14 dias de retenção.
// Sem rotação, o log vira um arquivo de 4 GB que ninguém abre.
$arquivo = new RotatingFileHandler(
filename: dirname(__DIR__, 3) . '/storage/logs/app.log',
maxFiles: 14,
level: $nivel,
);
$arquivo->setFormatter(Ambiente::texto('APP_ENV') === 'producao'
// JSON em produção: é o formato que Loki, Datadog e ELK indexam.
? new JsonFormatter()
: new LineFormatter(
format: "[%datetime%] %channel%.%level_name%: %message% %context%\n",
dateFormat: 'Y-m-d H:i:s',
allowInlineLineBreaks: true,
ignoreEmptyContextAndExtra: true,
));
$logger->pushHandler($arquivo);
// Handler 2 — erros também no stderr. Em container, é assim que a
// mensagem chega ao `docker logs` e ao orquestrador.
if (Ambiente::texto('APP_ENV') !== 'teste') {
$logger->pushHandler(new StreamHandler('php://stderr', Level::Error));
}
// Processors enriquecem TODO registro, sem tocar em quem chama o log.
$logger->pushProcessor(new PsrLogMessageProcessor());
$logger->pushProcessor(new IntrospectionProcessor(Level::Warning));
// Um id por request amarra as linhas de um mesmo acesso — é o que
// torna o log utilizável quando há 40 requisições por segundo.
$requestId = bin2hex(random_bytes(8));
$logger->pushProcessor(static function (array $registro) use ($requestId): array {
$registro['extra']['request_id'] = $requestId;
$registro['extra']['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'cli';
return $registro;
});
return $logger;
}
}
// ---------------------------------------- config/pdo.php
declare(strict_types=1);
use MeuProjeto\Suporte\Ambiente;
Ambiente::carregar(dirname(__DIR__));
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
Ambiente::texto('DB_HOST'),
Ambiente::inteiro('DB_PORT', 3306),
Ambiente::texto('DB_NAME'),
);
return new PDO($dsn, Ambiente::texto('DB_USER'), Ambiente::texto('DB_PASS', ''), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// ---------------------------------------- public/index.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use MeuProjeto\Infra\Log\Fabrica;
use MeuProjeto\Suporte\Ambiente;
Ambiente::carregar(dirname(__DIR__));
$log = Fabrica::criar('web');
// Contexto vai como ARRAY, não concatenado na mensagem: assim a linha em
// JSON tem campos consultáveis, e o PsrLogMessageProcessor ainda interpola
// os {placeholders} para a leitura humana.
$log->info('Usuário {id} entrou', ['id' => 42, 'plano' => 'pro']);
try {
$pdo = require __DIR__ . '/../config/pdo.php';
} catch (PDOException $e) {
// Nunca $e->getMessage() direto na tela: a mensagem do PDO traz DSN,
// usuário e às vezes a senha. Log para dentro, texto genérico para fora.
$log->critical('Falha ao conectar no banco', ['excecao' => $e]);
http_response_code(503);
exit('Serviço temporariamente indisponível.');
}
Exercício 3
Instale phpstan/phpstan como dependência de desenvolvimento. Execute vendor/bin/phpstan analyse src/ --level=5. Corrija todos os erros apontados.
Ver resposta
✓ Resposta: O erro de leitura mais comum é achar que o PHPStan pede tipo por burocracia. Repare no item 3: o SUM() de um conjunto vazio devolve NULL, e o código "que funcionava" retornava zero por coerção silenciosa — o analisador não inventou o problema, só o tornou visível. Em projeto grande, comece pelo --generate-baseline: sem isso, os 3.000 erros da primeira execução garantem que a ferramenta seja desinstalada no mesmo dia.
<?php
// ============ no terminal ============
// composer require --dev phpstan/phpstan
// vendor/bin/phpstan analyse src/ --level=5
// ============ phpstan.neon (na raiz) ============
// parameters:
// level: 5
// paths:
// - src
// - tests
// excludePaths:
// - src/Legado/*
// # Cada linha aqui é dívida assumida, não erro resolvido.
// # Escreva o porquê ao lado — daqui a seis meses ninguém lembra.
// ignoreErrors:
// - '#Call to an undefined method Mockery#'
//
// Com o arquivo pronto: vendor/bin/phpstan analyse
// ---------- ANTES: cinco erros que o nível 5 aponta -----------------------
declare(strict_types=1);
namespace MeuProjeto\Relatorio;
use PDO;
final class RelatorioAntes
{
private $pdo; // (1) sem tipo: property has no type
public function __construct($pdo) // (2) parâmetro sem tipo
{
$this->pdo = $pdo;
}
public function totalPorCliente($clienteId)
{
$stmt = $this->pdo->prepare('SELECT SUM(valor) FROM pedidos WHERE cliente_id = ?');
$stmt->execute([$clienteId]);
// (3) fetchColumn() devolve mixed|false — somar direto é o erro
// "binary operation * between mixed and int"
return $stmt->fetchColumn() * 100;
}
public function nomes(array $clientes)
{
$nomes = [];
foreach ($clientes as $cliente) {
// (4) "cannot access offset 'nome' on mixed" — array sem
// tipo de valor é array de mixed
$nomes[] = $cliente['nome'];
}
return $nomes; // (5) sem tipo de retorno
}
public function formatar(?string $nome): string
{
// (6) "parameter expects string, string|null given" — o ? do
// parâmetro não some sozinho
return strtoupper($nome);
}
}
// ---------- DEPOIS: mesmo comportamento, nível 5 limpo --------------------
declare(strict_types=1);
namespace MeuProjeto\Relatorio;
use PDO;
use RuntimeException;
final class RelatorioDepois
{
// (1) e (2): tipo na propriedade e promoção no construtor. readonly de
// brinde — o PHPStan passa a saber que ninguém troca o PDO no meio.
public function __construct(private readonly PDO $pdo) {}
public function totalPorCliente(int $clienteId): int
{
$stmt = $this->pdo->prepare('SELECT SUM(valor) FROM pedidos WHERE cliente_id = ?');
$stmt->execute([$clienteId]);
$soma = $stmt->fetchColumn();
// (3) O cast não é enfeite para calar o analisador: SUM() de conjunto
// vazio devolve NULL de verdade, e o código antigo silenciosamente
// fazia null * 100 = 0. Agora a intenção está escrita.
return $soma === false || $soma === null ? 0 : (int) ((float) $soma * 100);
}
/**
* (4) e (5): o docblock é onde o PHP não tem sintaxe para dizer "array
* de quê". `list<string>` é mais forte que `string[]` — promete também
* que as chaves são 0,1,2… sem buracos.
*
* @param list<array{nome: string, email?: string}> $clientes
* @return list<string>
*/
public function nomes(array $clientes): array
{
return array_map(
static fn (array $cliente): string => $cliente['nome'],
$clientes,
);
}
// (6) Ou o parâmetro deixa de aceitar null, ou o null é tratado.
// Escolher explicitamente é o ganho — antes, o comportamento com null
// era acidente.
public function formatar(?string $nome): string
{
return strtoupper($nome ?? 'SEM NOME');
}
public function exigirNome(?string $nome): string
{
if ($nome === null || trim($nome) === '') {
throw new RuntimeException('Nome é obrigatório.');
}
// Depois do if, o PHPStan sabe que aqui $nome é string não-vazia:
// é o "narrowing" que torna o analisador útil em vez de chato.
return strtoupper($nome);
}
}
// ============ o que cada nível acrescenta ============
// 0 classes e funções que não existem, número de argumentos
// 1 propriedades e métodos possivelmente indefinidos
// 2 todos os métodos, incluindo os de tipos vindos de docblock
// 3 tipos de retorno e de propriedade
// 4 código morto: condição sempre falsa, else inalcançável
// 5 TIPOS DOS ARGUMENTOS passados — onde a maioria dos bugs reais aparece
// 6 falta de anotação de tipo (o nível dos docblocks)
// 7 união de tipos parcialmente errada
// 8 chamada de método em tipo que pode ser null
// 9 uso de mixed com rigor
//
// Subir um nível por vez, corrigindo tudo antes do próximo, é a diferença
// entre adotar o PHPStan e desistir dele na primeira semana.
// ============ baseline: como adotar em projeto grande ============
// vendor/bin/phpstan analyse --generate-baseline
//
// Congela os erros existentes num phpstan-baseline.neon e falha só nos
// NOVOS. O acordo com o time é simples: ninguém acrescenta linha ao
// baseline; cada arquivo tocado sai dele.
Exercício 4
Desafio: publique uma mini biblioteca no GitHub (não precisa ir para o Packagist) com namespace próprio, testes, README e composer.json completo. Instale-a em outro projeto usando o repositório VCS do Composer.
Ver resposta
✓ Resposta: Duas coisas fazem a diferença entre uma biblioteca e uma pasta de código copiada. A primeira é a tag anotada: sem tag, o Composer só enxerga dev-main, e aí é preciso minimum-stability: dev no projeto consumidor — origem de metade dos "não instala". A segunda é publicar a interface junto da implementação: sem ConsultaDeCep, todo teste de quem usar a sua biblioteca vira teste de integração com rede.
<?php
// ============ estrutura da biblioteca ============
// cep-brasil/
// ├── composer.json
// ├── README.md
// ├── LICENSE
// ├── .gitignore → /vendor/ e composer.lock (biblioteca NÃO
// │ versiona o lock: quem manda é o app final)
// ├── phpunit.xml
// ├── src/
// │ ├── Cep.php
// │ ├── Endereco.php
// │ ├── CepInvalido.php
// │ ├── ConsultaDeCep.php
// │ └── Http/ClienteViaCep.php
// └── tests/
// ├── CepTest.php
// └── ConsultaDeCepTest.php
// ============ composer.json da BIBLIOTECA ============
// {
// "name": "voce/cep-brasil",
// "description": "Validação e consulta de CEP brasileiro",
// "type": "library",
// "license": "MIT",
// "authors": [ { "name": "Seu Nome", "email": "voce@exemplo.com" } ],
// "require": {
// "php": ">=8.2",
// "ext-json": "*"
// },
// "require-dev": {
// "phpunit/phpunit": "^11.0",
// "phpstan/phpstan": "^1.11"
// },
// "autoload": { "psr-4": { "Voce\\CepBrasil\\": "src/" } },
// "autoload-dev": { "psr-4": { "Voce\\CepBrasil\\Tests\\": "tests/" } },
// "scripts": {
// "teste": "phpunit",
// "analise": "phpstan analyse src --level=6"
// },
// "config": { "sort-packages": true }
// }
//
// Três decisões que separam biblioteca de aplicação:
// · require com FAIXA (">=8.2", "^11.0"), nunca versão exata — travar
// versão numa biblioteca é conflito garantido no projeto de quem usar
// · ext-json declarado: o Composer confere a extensão na instalação
// · composer.lock fora do git — quem resolve o grafo é o app final
// ---------------------------------------- src/CepInvalido.php
declare(strict_types=1);
namespace Voce\CepBrasil;
use InvalidArgumentException;
final class CepInvalido extends InvalidArgumentException
{
public static function formato(string $valor): self
{
return new self("CEP inválido: \"{$valor}\". Esperado 8 dígitos.");
}
}
// ---------------------------------------- src/Cep.php
declare(strict_types=1);
namespace Voce\CepBrasil;
use Stringable;
/** Objeto de valor: nasce válido, é imutável e se compara por valor. */
final class Cep implements Stringable
{
public readonly string $digitos;
public function __construct(string $valor)
{
$limpo = preg_replace('/\D/', '', $valor) ?? '';
if (strlen($limpo) !== 8) {
throw CepInvalido::formato($valor);
}
// 00000000 e 11111111 passam no formato e não existem no mundo.
if (preg_match('/^(\d)\1{7}$/', $limpo) === 1) {
throw CepInvalido::formato($valor);
}
$this->digitos = $limpo;
}
public function formatado(): string
{
return substr($this->digitos, 0, 5) . '-' . substr($this->digitos, 5);
}
public function equals(self $outro): bool
{
return $this->digitos === $outro->digitos;
}
public function __toString(): string
{
return $this->formatado();
}
}
// ---------------------------------------- src/Endereco.php
declare(strict_types=1);
namespace Voce\CepBrasil;
final class Endereco
{
public function __construct(
public readonly Cep $cep,
public readonly string $logradouro,
public readonly string $bairro,
public readonly string $cidade,
public readonly string $uf,
) {}
/** @param array<string, mixed> $dados resposta da API */
public static function daApi(array $dados): self
{
return new self(
new Cep((string) ($dados['cep'] ?? '')),
(string) ($dados['logradouro'] ?? ''),
(string) ($dados['bairro'] ?? ''),
(string) ($dados['localidade'] ?? ''),
(string) ($dados['uf'] ?? ''),
);
}
}
// ---------------------------------------- src/ConsultaDeCep.php
declare(strict_types=1);
namespace Voce\CepBrasil;
/**
* A interface é o contrato que o usuário da biblioteca vai dublar no teste
* DELE. Publicar só a classe concreta transformaria todo teste de quem usa
* num teste de integração com rede.
*/
interface ConsultaDeCep
{
public function buscar(Cep $cep): ?Endereco;
}
// ---------------------------------------- src/Http/ClienteViaCep.php
declare(strict_types=1);
namespace Voce\CepBrasil\Http;
use JsonException;
use Voce\CepBrasil\Cep;
use Voce\CepBrasil\ConsultaDeCep;
use Voce\CepBrasil\Endereco;
final class ClienteViaCep implements ConsultaDeCep
{
public function __construct(private readonly int $timeoutSegundos = 5) {}
public function buscar(Cep $cep): ?Endereco
{
$contexto = stream_context_create(['http' => [
'timeout' => $this->timeoutSegundos,
'ignore_errors' => true,
'header' => "User-Agent: cep-brasil/1.0\r\n",
]]);
$json = @file_get_contents(
"https://viacep.com.br/ws/{$cep->digitos}/json/",
context: $contexto,
);
if ($json === false) {
return null;
}
try {
$dados = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
// A ViaCEP responde 200 com {"erro": true} para CEP inexistente —
// conferir só o código HTTP não basta.
if (!is_array($dados) || ($dados['erro'] ?? false)) {
return null;
}
return Endereco::daApi($dados);
}
}
// ---------------------------------------- tests/CepTest.php
declare(strict_types=1);
namespace Voce\CepBrasil\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Voce\CepBrasil\Cep;
use Voce\CepBrasil\CepInvalido;
final class CepTest extends TestCase
{
#[DataProvider('cepsValidos')]
public function testAceitaComOuSemMascara(string $entrada, string $esperado): void
{
$this->assertSame($esperado, (new Cep($entrada))->formatado());
}
/** @return iterable<string, array{string, string}> */
public static function cepsValidos(): iterable
{
yield 'com hífen' => ['01310-100', '01310-100'];
yield 'só dígitos' => ['01310100', '01310-100'];
yield 'com ponto' => ['01.310-100', '01310-100'];
yield 'com espaço' => [' 01310 100 ', '01310-100'];
}
#[DataProvider('cepsInvalidos')]
public function testRecusaEntradaInvalida(string $entrada): void
{
$this->expectException(CepInvalido::class);
new Cep($entrada);
}
/** @return iterable<string, array{string}> */
public static function cepsInvalidos(): iterable
{
yield 'curto' => ['0131010'];
yield 'longo' => ['013101000'];
yield 'vazio' => [''];
yield 'só letras' => ['abcdefgh'];
yield 'repetido' => ['00000000'];
}
public function testComparacaoEPorValor(): void
{
$this->assertTrue((new Cep('01310-100'))->equals(new Cep('01310100')));
}
}
// ============ publicando (sem Packagist) ============
// git init && git add . && git commit -m "Primeira versão"
// git remote add origin git@github.com:voce/cep-brasil.git
// git push -u origin main
//
// A TAG é o que vira versão instalável — anotada, com mensagem própria:
// git tag -a v1.0.0 -m "Primeira versão estável"
// git push origin v1.0.0
// ============ composer.json de QUEM CONSOME ============
// {
// "repositories": [
// { "type": "vcs", "url": "https://github.com/voce/cep-brasil" }
// ],
// "require": {
// "voce/cep-brasil": "^1.0"
// }
// }
//
// composer update voce/cep-brasil
//
// O Composer lê o composer.json de cada tag do repositório e monta as
// versões disponíveis. Repositório privado funciona igual, com chave SSH
// (url no formato git@github.com:voce/cep-brasil.git) ou token.
//
// SEM tag nenhuma, a única referência é o branch:
// "require": { "voce/cep-brasil": "dev-main" },
// "minimum-stability": "dev",
// "prefer-stable": true
//
// É o atalho que todo mundo usa no começo e o motivo de metade dos
// "por que não instala": sem tag e sem minimum-stability, o Composer
// recusa o pacote sem explicar direito.
// ---------------------------------------- uso no projeto que consome
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Voce\CepBrasil\Cep;
use Voce\CepBrasil\CepInvalido;
use Voce\CepBrasil\Http\ClienteViaCep;
try {
$endereco = (new ClienteViaCep())->buscar(new Cep('01310-100'));
echo $endereco === null
? "CEP não encontrado.\n"
: "{$endereco->logradouro}, {$endereco->bairro} — {$endereco->cidade}/{$endereco->uf}\n";
} catch (CepInvalido $e) {
echo $e->getMessage(), "\n";
}
Exercício 5
Monte um composer.json que não tem solução possível — peça monolog/monolog na versão 3 e, junto, psr/log na versão 1 — e rode o composer install. Leia a mensagem de erro inteira e diga, com suas palavras, qual linha explica o problema. Depois use composer why e composer why-not para mapear quem exige o quê, e apresente três formas diferentes de resolver, explicando quando cada uma é a escolha certa.
Ver resposta
✓ Resposta: A mensagem do Composer assusta pelo tamanho, mas se lê de baixo para cima: a última linha de cada "Problem" é a que contém o conflito real, e as de cima são só o caminho que levou até ele. Aqui ela diz que o Monolog 3 exige psr/log ^2.0 || ^3.0 enquanto o seu arquivo trava em ^1.0 — não existe versão que sirva aos dois, e por isso nada é instalado. O composer why mostra quem depende de um pacote, e o why-not faz o contrário: explica por que determinada versão não entra. Das três saídas, a primeira é quase sempre a certa — o trava-versão costuma ser um require que você mesmo escreveu sem necessidade, copiado de um tutorial antigo. Vale notar o que não resolve: --ignore-platform-reqs apenas mente sobre o ambiente, e apagar o composer.lock devolve o mesmo erro alguns segundos depois. Conflito de dependência é aritmética de intervalos; se a interseção é vazia, alguma exigência precisa ceder.
# ── 1. Provoque o conflito ────────────────────────────────────────────
# composer.json pedindo o Monolog 3 e, ao mesmo tempo, a psr/log 1:
{
"require": {
"monolog/monolog": "^3.0",
"psr/log": "^1.0"
}
}
# ── 2. Leia a mensagem de baixo para cima ────────────────────────────
$ composer install
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires monolog/monolog ^3.0
-> satisfiable by monolog/monolog[3.0.0, ..., 3.12.0].
- monolog/monolog[3.0.0, ..., 3.12.0] require psr/log ^2.0 || ^3.0
-> found psr/log[2.0.0, 3.0.0, 3.0.1, 3.0.2]
but it conflicts with your root composer.json require (^1.0).
# A última linha é a que interessa: o Monolog 3 quer psr/log 2 ou 3, e o seu
# composer.json trava em 1. Não há interseção — nenhuma versão serve aos dois.
# ── 3. Descubra quem exige o quê ──────────────────────────────────────
$ composer why psr/log
teste/conflito - requires psr/log (^3.0)
monolog/monolog 3.12.0 requires psr/log (^2.0 || ^3.0)
# O caminho inverso, para entender por que uma versão específica não entra:
$ composer why-not psr/log 1.1.4
# ── 4. Resolva ────────────────────────────────────────────────────────
# Três saídas, em ordem de preferência:
#
# a) Afrouxar o seu próprio require, quando o trava-versão era você:
# "psr/log": "^3.0" → resolve, e é o caso deste exemplo.
#
# b) Baixar a versão do pacote que puxou a exigência nova:
# "monolog/monolog": "^2.0" → o Monolog 2 aceita psr/log ^1.0.
#
# c) Deixar o Composer escolher, em vez de decidir por ele:
# composer require monolog/monolog (sem fixar a versão)
#
# O que NÃO resolve é --ignore-platform-reqs nem apagar o composer.lock:
# o primeiro mente sobre o ambiente e o segundo só adia o mesmo erro.
# ── 5. Confirme antes de gravar ───────────────────────────────────────
$ composer update --dry-run # mostra o que mudaria, sem tocar em nada
$ composer audit # vulnerabilidades conhecidas no que está instalado