Composer e Namespaces

Composer e Namespaces

Namespaces que evitam colisão de nomes, PSR-4 que dispensa o require manual e o composer.json de ponta a ponta. Com o significado real do acento circunflexo antes da versão 1.0, a mudança de API do Monolog 3 e como se lê a mensagem quando a resolução falha.
PHP

• • 27 min de leitura

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

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
Comentários

Mais em PHP

Orientação a Objetos: Fundamentos
Orientação a Objetos: Fundamentos

Uma classe define o molde; cada objeto guarda o próprio estado. Construtor…

Estruturas de Controle
Estruturas de Controle

Decidir é o que separa um script de um programa. Como o PHP encadeia condições…

APIs REST com PHP
APIs REST com PHP

Um roteador que traduz {param} em regex, um envelope JSON que o cliente…