Namespaces, Autoloading, PSR-4 e Composer

Namespaces, Autoloading, PSR-4 e Composer

Sem namespace, dois arquivos com a mesma classe brigam pelo mesmo nome. O PSR-4 resolve isso amarrando namespace a pasta, e o Composer transforma a regra em autoload gerado. Como o mapeamento funciona, o que o composer.lock garante e por que as constraints de versão importam mais do que parecem.
PHP

23 min de leitura

Até agora todos os exemplos couberam em um único arquivo. Na prática, um projeto real tem dezenas ou centenas de classes — e precisamos de uma forma de organizá-las em pastas, evitar conflitos de nomes e carregá-las automaticamente sem precisar escrever centenas de require. Três ferramentas resolvem isso juntas: namespaces, autoloading PSR-4 e Composer.

Este artigo cobre o que todo desenvolvedor PHP precisa saber antes de trabalhar com qualquer framework: como estruturar um projeto, como o PHP sabe onde encontrar uma classe, e como o Composer gerencia as dependências externas.

O problema sem namespaces

Antes dos namespaces (PHP 5.3), todas as classes compartilhavam o mesmo espaço de nomes global. Instalar duas bibliotecas que definiam uma classe Request causava conflito fatal. A solução era prefixar nomes com strings longas — Zend_Http_Client_Request — criando nomes impraticáveis. Namespaces resolvem isso de forma elegante:

<?php
// Problema: duas bibliotecas definem a mesma classe
// require 'vendor/framework-a/Request.php'; // define class Request
// require 'vendor/framework-b/Request.php'; // Fatal: Cannot redeclare class Request

// Com namespaces — cada biblioteca vive no seu próprio "espaço"
// FrameworkA\Http\Request e FrameworkB\Http\Request coexistem sem conflito

Declarando e usando namespaces

A declaração namespace deve ser a primeira instrução do arquivo (antes de qualquer código, incluindo espaços em branco). O separador \ cria sub-namespaces que convencionalmente espelham a estrutura de pastas:

src/Http/Request.php

<?php
declare(strict_types=1);

// namespace deve ser a primeira instrução do arquivo
namespace MeuApp\Http;

class Request
{
    private array $dados;

    public function __construct()
    {
        // Captura os dados da requisição HTTP atual
        $this->dados = [
            "method"  => $_SERVER["REQUEST_METHOD"] ?? "GET",
            "uri"     => $_SERVER["REQUEST_URI"]    ?? "/",
            "input"   => $_POST,
            "query"   => $_GET,
        ];
    }

    public function method(): string   { return $this->dados["method"]; }
    public function uri(): string      { return $this->dados["uri"]; }
    public function input(string $k): mixed { return $this->dados["input"][$k] ?? null; }
}

src/Http/Response.php

<?php
declare(strict_types=1);

namespace MeuApp\Http;

class Response
{
    private int    $status  = 200;
    private array  $headers = [];
    private string $corpo   = "";

    public function status(int $codigo): static
    {
        $this->status = $codigo;
        return $this;
    }

    public function header(string $nome, string $valor): static
    {
        $this->headers[] = "{$nome}: {$valor}";
        return $this;
    }

    public function json(array $dados): static
    {
        $this->header("Content-Type", "application/json");
        $this->corpo = json_encode($dados, JSON_UNESCAPED_UNICODE);
        return $this;
    }

    public function enviar(): void
    {
        http_response_code($this->status);
        foreach ($this->headers as $h) header($h);
        echo $this->corpo;
    }
}

Importando com use

Em vez de escrever o nome completo MeuApp\Http\Request toda vez, usamos use para importar a classe. Isso vale apenas dentro do arquivo atual — não "inclui" o arquivo, apenas cria um alias para o nome completo:

src/Controllers/ProdutoController.php

<?php
declare(strict_types=1);

namespace MeuApp\Controllers;

// use importa a classe pelo nome completo — use depois de namespace
use MeuApp\Http\Request;
use MeuApp\Http\Response;
use MeuApp\Models\Produto;
use InvalidArgumentException;   // classes nativas ficam no namespace global

// Agora pode usar só "Request" no lugar de "MeuApp\Http\Request"
class ProdutoController
{
    public function listar(Request $req, Response $res): void
    {
        $produtos = Produto::todos();
        $res->json($produtos)->enviar();
    }

    public function criar(Request $req, Response $res): void
    {
        $nome  = $req->input("nome");
        $preco = $req->input("preco");

        if (!$nome || !$preco) {
            throw new InvalidArgumentException("Nome e preço são obrigatórios.");
        }

        $produto = new Produto($nome, (float) $preco);
        $res->status(201)->json(["id" => $produto->getId()])->enviar();
    }
}

// Aliases — quando dois namespaces têm o mesmo nome de classe
// use MeuApp\Http\Request as HttpRequest;
// use OutraLib\Request as OutraRequest;

// Importação em grupo — PHP 7+
// use MeuApp\Http\{Request, Response, Middleware};

⚠️ Namespace global e classes nativas
Classes nativas do PHP como DateTime, InvalidArgumentException e stdClass vivem no namespace global (sem prefixo). Dentro de um arquivo com namespace, você precisa ou importá-las com use ou prefixá-las com \ — por exemplo \DateTime. O declare(strict_types=1) deve vir antes do namespace.

Estrutura de projeto padrão

A convenção da comunidade PHP para projetos modernos segue esta estrutura. O namespace raiz da aplicação (MeuApp) mapeia diretamente para a pasta src/:

meu-projeto/
├── src/                         ← namespace raiz: MeuApp\
│   ├── Controllers/
│   │   └── ProdutoController.php  ← MeuApp\Controllers\ProdutoController
│   ├── Models/
│   │   └── Produto.php            ← MeuApp\Models\Produto
│   ├── Http/
│   │   ├── Request.php            ← MeuApp\Http\Request
│   │   └── Response.php           ← MeuApp\Http\Response
│   ├── Services/
│   │   └── PagamentoService.php   ← MeuApp\Services\PagamentoService
│   └── Exceptions/
│       └── AppException.php       ← MeuApp\Exceptions\AppException
├── tests/                       ← namespace: MeuApp\Tests\
├── public/
│   └── index.php                ← ponto de entrada da aplicação
├── vendor/                      ← gerado pelo Composer — nunca editar
├── composer.json
└── composer.lock

💡 A regra PSR-4
A PSR-4 é uma especificação da PHP-FIG que define como o namespace de uma classe deve corresponder ao caminho do arquivo. Regra: VendorName\SubNamespace\ClassName mapeia para vendor_dir/SubNamespace/ClassName.php. Um arquivo, uma classe. O nome do arquivo deve ser idêntico (inclusive capitalização) ao nome da classe.

PSR-4 — o mapeamento namespace → pasta

A PSR-4 formaliza a convenção que já usamos nos exemplos. O Composer implementa esse mapeamento automaticamente:

Namespace completo da classe Caminho do arquivo
MeuApp\Http\Request src/Http/Request.php
MeuApp\Controllers\ProdutoController src/Controllers/ProdutoController.php
MeuApp\Models\Produto src/Models/Produto.php
MeuApp\Exceptions\AppException src/Exceptions/AppException.php
MeuApp\Tests\Models\ProdutoTest tests/Models/ProdutoTest.php

Composer — gerenciador de dependências

O Composer é a ferramenta padrão para gerenciar dependências PHP. Ele faz três coisas essenciais: baixa e instala bibliotecas externas, garante compatibilidade de versões entre dependências, e gera o autoloader PSR-4 que carrega automaticamente qualquer classe do projeto.

composer.json — o coração do projeto

O composer.json descreve o projeto e suas dependências. O bloco autoload é o que conecta namespaces a pastas:

{
    "name": "meuusuario/meu-projeto",
    "description": "Minha aplicação PHP",
    "type": "project",
    "require": {
        "php": "^8.2",
        "guzzlehttp/guzzle": "^7.0",
        "vlucas/phpdotenv": "^5.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0",
        "phpstan/phpstan": "^1.0"
    },
    "autoload": {
        "psr-4": {
            "MeuApp\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "MeuApp\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit",
        "analyse": "phpstan analyse src --level=8"
    },
    "config": {
        "sort-packages": true,
        "optimize-autoloader": true
    }
}

Os principais comandos do Composer

# Cria interativamente o composer.json de um novo projeto
composer init

# Instala as dependências listadas no composer.lock (ideal para CI/deploy)
composer install

# Adiciona uma dependência nova ao projeto e instala imediatamente
composer require guzzlehttp/guzzle

# Adiciona como dependência de desenvolvimento apenas
composer require --dev phpunit/phpunit

# Atualiza todas as dependências para a versão mais recente compatível
composer update

# Remove uma dependência do projeto
composer remove vlucas/phpdotenv

# Regenera o autoloader — necessário após adicionar classes ou mudar o autoload
composer dump-autoload

# Gera autoloader otimizado (classmap) para produção — mais rápido que PSR-4 puro
composer dump-autoload -o

Como o autoloading funciona

Quando você executa composer install ou composer dump-autoload, o Composer gera o arquivo vendor/autoload.php. Basta incluí-lo uma única vez no ponto de entrada da aplicação — depois disso, qualquer classe com namespace configurado é carregada automaticamente quando usada pela primeira vez:

public/index.php

<?php
declare(strict_types=1);

// Este é o ÚNICO require necessário em toda a aplicação
// O Composer cuida de carregar todas as outras classes automaticamente
require_once __DIR__ . "/../vendor/autoload.php";

use MeuApp\Http\Request;
use MeuApp\Http\Response;
use MeuApp\Controllers\ProdutoController;

// PHP tenta usar MeuApp\Http\Request pela primeira vez →
// autoloader busca src/Http/Request.php (PSR-4) →
// inclui o arquivo automaticamente — nenhum require manual necessário

$request    = new Request();
$response   = new Response();
$controller = new ProdutoController();

// Roteamento simples baseado na URI
match($request->uri()) {
    "/produtos"       => $controller->listar($request, $response),
    "/produtos/criar" => $controller->criar($request, $response),
    default           => $response->status(404)->json(["erro" => "Rota não encontrada"])->enviar(),
};

O mecanismo interno do autoloader

Por baixo dos panos, o Composer registra uma função com spl_autoload_register(). Entender esse mecanismo ajuda quando você precisar criar autoloaders customizados ou depurar problemas de carregamento:

<?php
declare(strict_types=1);

// O que o Composer faz internamente — simplificado
// Normalmente você NÃO escreve isso — use composer dump-autoload

spl_autoload_register(function (string $classeCompleta): void {
    // Prefixo raiz que este autoloader conhece
    $prefixo  = "MeuApp\\";
    $pastaSrc = __DIR__ . "/src/";

    // Ignora classes que não pertencem a este namespace
    if (!str_starts_with($classeCompleta, $prefixo)) {
        return;
    }

    // Remove o prefixo, troca \ por /, adiciona .php
    $relativo = substr($classeCompleta, strlen($prefixo));
    $arquivo  = $pastaSrc . str_replace("\\", "/", $relativo) . ".php";

    if (file_exists($arquivo)) {
        require $arquivo;
    }
});

// Passo a passo para "MeuApp\Http\Request":
// 1. str_starts_with → true (começa com "MeuApp\\")
// 2. substr remove "MeuApp\\" → "Http\Request"
// 3. str_replace "\\" por "/" → "Http/Request"
// 4. arquivo final: /caminho/src/Http/Request.php
// 5. file_exists → require o arquivo

Versionamento semântico e constraints

O Composer usa versionamento semântico (MAJOR.MINOR.PATCH) para controlar quais versões de uma dependência são compatíveis com o seu projeto. Entender as constraints evita surpresas ao atualizar dependências:

Constraint Significado Exemplo de versões aceitas
"^7.0" Caret — aceita MINOR e PATCH livres 7.0, 7.1, 7.9 — não aceita 8.0
"~7.1" Tilde — aceita apenas incrementos de PATCH 7.1, 7.2 — não aceita 8.0
">=7.0 <8.0" Intervalo explícito 7.0 a 7.x — não aceita 8.0
"7.1.*" Wildcard — fixa MAJOR e MINOR 7.1.0, 7.1.5 — não aceita 7.2
"7.1.3" Versão exata — não recomendado apenas 7.1.3
"dev-main" Branch de desenvolvimento — instável commit mais recente da branch main

composer.lock — sempre commitar no Git
O composer.lock registra as versões exatas instaladas. Quando outro desenvolvedor (ou o servidor de deploy) roda composer install, ele recebe exatamente as mesmas versões — não as mais recentes compatíveis com a constraint. Sempre commite o composer.lock. Só rode composer update quando quiser atualizar intencionalmente as dependências e retestar a aplicação.

Projeto prático — biblioteca de domínio com PSR-4

Vamos montar um módulo de pedidos completo para consolidar namespaces, autoloading e boas práticas de organização. Este é o tipo de código que você verá em projetos Laravel, Symfony e sistemas próprios:

src/Pedidos/Exceptions/PedidoException.php

<?php
declare(strict_types=1);

namespace MeuApp\Pedidos\Exceptions;

use RuntimeException;

// Sub-namespace dentro de Pedidos — agrupa tudo relacionado a pedidos
class PedidoException extends RuntimeException {}

class ProdutoEsgotadoException extends PedidoException
{
    public function __construct(
        public readonly string $nomeProduto,
        public readonly int    $estoqueAtual,
    ) {
        parent::__construct(
            "Produto '{$nomeProduto}' com estoque insuficiente ({$estoqueAtual} disponíveis)."
        );
    }
}

src/Pedidos/ItemPedido.php

<?php
declare(strict_types=1);

namespace MeuApp\Pedidos;

final class ItemPedido
{
    public function __construct(
        public readonly string $nome,
        public readonly float  $precoUnitario,
        public readonly int    $quantidade,
    ) {}

    public function subtotal(): float
    {
        return $this->precoUnitario * $this->quantidade;
    }
}

src/Pedidos/Pedido.php

<?php
declare(strict_types=1);

namespace MeuApp\Pedidos;

use MeuApp\Pedidos\Exceptions\ProdutoEsgotadoException;
use DateTimeImmutable;

class Pedido
{
    private array  $itens   = [];
    private string $status  = "rascunho";
    private DateTimeImmutable $criadoEm;

    public function __construct(
        private readonly string $clienteNome,
    ) {
        $this->criadoEm = new DateTimeImmutable();
    }

    public function adicionarItem(
        string $nome,
        float  $preco,
        int    $qtd,
        int    $estoqueDisponivel,
    ): static {
        if ($qtd > $estoqueDisponivel) {
            throw new ProdutoEsgotadoException($nome, $estoqueDisponivel);
        }
        $this->itens[] = new ItemPedido($nome, $preco, $qtd);
        return $this;
    }

    public function confirmar(): static
    {
        if (empty($this->itens)) {
            throw new Exceptions\PedidoException("Pedido sem itens não pode ser confirmado.");
        }
        $this->status = "confirmado";
        return $this;
    }

    public function total(): float
    {
        return array_sum(array_map(
            fn(ItemPedido $i) => $i->subtotal(),
            $this->itens
        ));
    }

    public function resumo(): string
    {
        $total = number_format($this->total(), 2, ",", ".");
        return sprintf(
            "Pedido de %s | %d item(ns) | Total: R$ %s | Status: %s",
            $this->clienteNome,
            count($this->itens),
            $total,
            $this->status
        );
    }
}

public/index.php — usando o módulo

<?php
declare(strict_types=1);

require_once __DIR__ . "/../vendor/autoload.php";

use MeuApp\Pedidos\Pedido;
use MeuApp\Pedidos\Exceptions\ProdutoEsgotadoException;

try {
    $pedido = (new Pedido("Maria Silva"))
        ->adicionarItem("Teclado", 350.0, 1, 10)
        ->adicionarItem("Mouse",   180.0, 2, 5)
        ->confirmar();

    echo $pedido->resumo();
    // Pedido de Maria Silva | 2 item(ns) | Total: R$ 710,00 | Status: confirmado

} catch (ProdutoEsgotadoException $e) {
    echo "Erro: " . $e->getMessage();
    echo "Produto: " . $e->nomeProduto;
}

Boas práticas de organização

Um arquivo, uma classe, um namespace. Esta é a regra fundamental da PSR-1. Nunca declare múltiplas classes em um mesmo arquivo. O nome do arquivo deve ser exatamente igual ao nome da classe — capitalização incluída. ProdutoController.php, não produtocontroller.php.

Namespace espelha a estrutura de pastas. MeuApp\Services\PagamentoService deve estar em src/Services/PagamentoService.php. Isso não é apenas convenção — é o que permite o autoloader encontrar o arquivo. Desviar disso significa escrever configuração extra no composer.json.

Nunca edite a pasta vendor/. Tudo dentro de vendor/ é gerado e regenerado pelo Composer. Adicione vendor/ ao .gitignore e documente no README que é necessário rodar composer install após clonar o repositório.

Prefira composer install a composer update em produção. O install respeita o composer.lock e garante que você está instalando as versões testadas. O update atualiza para as versões mais recentes dentro das constraints e deve ser feito com intenção e retestado antes de ir para produção.

O que começou como organização de nomes terminou mudando a forma de trabalhar: com PSR-4 e Composer, incluir arquivo à mão deixou de existir, e o ecossistema passou a ser feito de pacotes que se encaixam sem combinação prévia. O preço é uma disciplina pequena e inegociável — um namespace por pasta, um nome de classe por arquivo, e o composer.lock versionado junto do código. Quem quebra qualquer uma das três descobre na hora, porque o autoload simplesmente não acha a classe.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie um projeto do zero com composer init. Configure o autoload PSR-4 mapeando o namespace Loja\ para a pasta src/. Crie as classes Loja\Models\Produto, Loja\Models\Categoria e Loja\Services\CatalogoService com namespace correto. Teste rodando php public/index.php.

Ver resposta

✓ Resposta: Se der "class not found", o suspeito é quase sempre um destes três: rodou composer dump-autoload depois de mexer no composer.json? A barra dupla em "Loja\\" está lá (JSON escapa a barra invertida)? O nome do arquivo bate com o da classe, com a mesma caixa? Em Linux, produto.phpProduto.php.

<?php
// ============ composer.json ============
// {
//     "name": "voce/loja",
//     "autoload": {
//         "psr-4": { "Loja\\": "src/" }
//     },
//     "require": { "php": ">=8.1" }
// }
//
// A regra do PSR-4 é uma só: o prefixo do namespace aponta para uma pasta, e
// o resto do nome vira caminho. Loja\Models\Produto → src/Models/Produto.php
//
// Depois de editar o composer.json:  composer dump-autoload

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

namespace Loja\Models;

class Produto
{
    public function __construct(
        public readonly string $nome,
        public readonly int $precoCentavos,
        public readonly Categoria $categoria,
    ) {}

    public function precoFormatado(): string
    {
        return 'R$ ' . number_format($this->precoCentavos / 100, 2, ',', '.');
    }
}

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

namespace Loja\Models;

class Categoria
{
    public function __construct(
        public readonly string $nome,
        public readonly string $slug,
    ) {}
}

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

namespace Loja\Services;

// `use` traz o nome de outro namespace para este arquivo — sem ele, seria
// preciso escrever \Loja\Models\Produto por extenso a cada menção.
use Loja\Models\Produto;
use Loja\Models\Categoria;

class CatalogoService
{
    /** @var Produto[] */
    private array $produtos = [];

    public function adicionar(Produto $produto): void
    {
        $this->produtos[] = $produto;
    }

    /** @return Produto[] */
    public function porCategoria(string $slug): array
    {
        return array_values(array_filter(
            $this->produtos,
            static fn(Produto $p): bool => $p->categoria->slug === $slug,
        ));
    }
}

// ---------------------------------------- public/index.php
declare(strict_types=1);

// O único require do projeto inteiro.
require __DIR__ . '/../vendor/autoload.php';

use Loja\Models\Categoria;
use Loja\Models\Produto;
use Loja\Services\CatalogoService;

$perifericos = new Categoria('Periféricos', 'perifericos');

$catalogo = new CatalogoService();
$catalogo->adicionar(new Produto('Teclado mecânico', 34990, $perifericos));
$catalogo->adicionar(new Produto('Mouse sem fio', 12950, $perifericos));

foreach ($catalogo->porCategoria('perifericos') as $produto) {
    echo $produto->nome, ' — ', $produto->precoFormatado(), PHP_EOL;
}

// Teclado mecânico — R$ 349,90
// Mouse sem fio — R$ 129,50

Exercício 2

Instale o pacote vlucas/phpdotenv com Composer. Crie um arquivo .env com variáveis de configuração (APP_NAME, DB_HOST, DB_PORT). No public/index.php, carregue as variáveis e exiba-as com $_ENV ou getenv().

Ver resposta

✓ Resposta: Duas armadilhas: createImmutable recusa sobrescrever variável que já existe no ambiente (é o que você quer em produção), e o .env nunca vai para o Git — versiona-se um .env.example com valores de mentira.

<?php
// public/index.php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

// composer require vlucas/phpdotenv
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');

// safeLoad não explode se o .env não existir (útil em produção, onde as
// variáveis costumam vir do ambiente e não de arquivo).
$dotenv->safeLoad();

// Declarar o que é obrigatório é o que transforma erro de config em erro
// claro no boot, em vez de "undefined index" três camadas adiante.
$dotenv->required(['APP_NAME', 'DB_HOST', 'DB_PORT']);
$dotenv->required('DB_PORT')->isInteger();

echo $_ENV['APP_NAME'], PHP_EOL;
echo $_ENV['DB_HOST'], ':', $_ENV['DB_PORT'], PHP_EOL;

Exercício 3

Crie um namespace Loja\Exceptions com uma hierarquia de exceções: LojaException (base), ProdutoNaoEncontradoException e EstoqueInsuficienteException — cada uma com propriedades relevantes e mensagem descritiva. Use-as em CatalogoService.

Ver resposta

✓ Resposta: O ganho da base abstrata aparece no catch: dá para tratar qualquer erro de domínio da loja num ponto só, sem capturar PDOException ou TypeError junto. E cada exceção carrega os dados do erro como propriedade — não só uma string.

<?php
// ---------------------------------------- src/Exceptions/LojaException.php
declare(strict_types=1);

namespace Loja\Exceptions;

// Base de todas: permite `catch (LojaException $e)` para pegar qualquer erro
// de domínio da loja, sem capturar erro de infraestrutura junto.
// A barra em \RuntimeException é obrigatória: sem ela o PHP procuraria
// Loja\Exceptions\RuntimeException, que não existe.
abstract class LojaException extends \RuntimeException
{
}

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

namespace Loja\Exceptions;

final class ProdutoNaoEncontradoException extends LojaException
{
    public function __construct(public readonly int $produtoId)
    {
        parent::__construct("Produto #{$produtoId} não encontrado no catálogo.");
    }
}

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

namespace Loja\Exceptions;

final class EstoqueInsuficienteException extends LojaException
{
    public function __construct(
        public readonly int $produtoId,
        public readonly int $solicitado,
        public readonly int $disponivel,
    ) {
        parent::__construct(sprintf(
            'Estoque insuficiente para o produto #%d: pedidas %d unidade(s), há %d.',
            $produtoId, $solicitado, $disponivel,
        ));
    }

    public function faltam(): int
    {
        return $this->solicitado - $this->disponivel;
    }
}

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

namespace Loja\Services;

use Loja\Exceptions\EstoqueInsuficienteException;
use Loja\Exceptions\ProdutoNaoEncontradoException;
use Loja\Models\Produto;

class CatalogoService
{
    /** @param array<int, Produto> $produtos  @param array<int, int> $estoque */
    public function __construct(
        private array $produtos = [],
        private array $estoque = [],
    ) {}

    public function buscar(int $id): Produto
    {
        // Devolver o produto ou explodir — nunca null. Quem chama não precisa
        // testar retorno, e o erro carrega o id que faltou.
        return $this->produtos[$id] ?? throw new ProdutoNaoEncontradoException($id);
    }

    public function reservar(int $id, int $quantidade): void
    {
        $this->buscar($id);   // valida a existência primeiro

        $disponivel = $this->estoque[$id] ?? 0;

        if ($quantidade > $disponivel) {
            throw new EstoqueInsuficienteException($id, $quantidade, $disponivel);
        }

        $this->estoque[$id] -= $quantidade;
    }
}

// ---------------------------------------- uso
use Loja\Exceptions\EstoqueInsuficienteException;
use Loja\Exceptions\LojaException;

try {
    $catalogo->reservar(7, 10);
} catch (EstoqueInsuficienteException $e) {
    // Tratamento específico: dá para dizer quantas faltam.
    echo $e->getMessage(), " Faltam {$e->faltam()}.", PHP_EOL;
} catch (LojaException $e) {
    // Rede de segurança para qualquer outro erro de domínio da loja.
    echo 'Erro no catálogo: ', $e->getMessage(), PHP_EOL;
}

Exercício 4

Instale o phpunit/phpunit como dependência de desenvolvimento. Configure o autoload-dev no composer.json para o namespace Loja\Tests\ apontando para tests/. Escreva um teste básico para CatalogoService.

Ver resposta

✓ Resposta: O autoload-dev existe para que Loja\Tests\ não vá para produção: composer install --no-dev simplesmente não registra esse mapeamento.

<?php
// ============ composer.json ============
// {
//     "autoload":     { "psr-4": { "Loja\\":       "src/"   } },
//     "autoload-dev": { "psr-4": { "Loja\\Tests\\": "tests/" } },
//     "require-dev":  { "phpunit/phpunit": "^11.0" },
//     "scripts":      { "test": "phpunit" }
// }
//
// composer require --dev phpunit/phpunit
// composer dump-autoload

// ============ phpunit.xml ============
// <?xml version="1.0" encoding="UTF-8"?>
// <phpunit bootstrap="vendor/autoload.php" colors="true">
//     <testsuites>
//         <testsuite name="Loja">
//             <directory>tests</directory>
//         </testsuite>
//     </testsuites>
// </phpunit>

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

namespace Loja\Tests;

use Loja\Exceptions\EstoqueInsuficienteException;
use Loja\Exceptions\ProdutoNaoEncontradoException;
use Loja\Models\Categoria;
use Loja\Models\Produto;
use Loja\Services\CatalogoService;
use PHPUnit\Framework\TestCase;

final class CatalogoServiceTest extends TestCase
{
    private CatalogoService $catalogo;

    // Roda antes de CADA teste: um não enxerga o estado deixado pelo outro.
    protected function setUp(): void
    {
        $categoria = new Categoria('Periféricos', 'perifericos');

        $this->catalogo = new CatalogoService(
            produtos: [7 => new Produto('Teclado mecânico', 34990, $categoria)],
            estoque:  [7 => 3],
        );
    }

    public function testBuscaProdutoExistente(): void
    {
        $produto = $this->catalogo->buscar(7);

        self::assertSame('Teclado mecânico', $produto->nome);
        self::assertSame('R$ 349,90', $produto->precoFormatado());
    }

    public function testProdutoInexistenteLancaExcecao(): void
    {
        // A expectativa vem ANTES da chamada que deve falhar.
        $this->expectException(ProdutoNaoEncontradoException::class);
        $this->expectExceptionMessage('Produto #99 não encontrado');

        $this->catalogo->buscar(99);
    }

    public function testReservaAlemDoEstoqueLancaExcecao(): void
    {
        try {
            $this->catalogo->reservar(7, 10);
            self::fail('Deveria ter lançado EstoqueInsuficienteException.');
        } catch (EstoqueInsuficienteException $e) {
            // Testar os dados da exceção, não só o tipo.
            self::assertSame(7, $e->produtoId);
            self::assertSame(7, $e->faltam());   // 10 pedidas - 3 disponíveis
        }
    }

    public function testReservaDentroDoEstoquePassa(): void
    {
        $this->catalogo->reservar(7, 2);

        // Sobrou 1: a segunda reserva de 2 tem de falhar.
        $this->expectException(EstoqueInsuficienteException::class);
        $this->catalogo->reservar(7, 2);
    }
}

// Rodar:  ./vendor/bin/phpunit   ou   composer test

Exercício 5

Desafio: crie um mini-framework de rotas com namespace próprio. Interface MeuApp\Routing\RotaInterface com método executar(array $params): void, classe Roteador que registra rotas e despacha com base na URI, e ao menos dois controllers (HomeController, ApiController). Tudo carregado via autoloader do Composer, zero require manuais além do vendor/autoload.php.

Ver resposta

✓ Resposta: O ponto do desafio é o que não aparece: nenhum require além do vendor/autoload.php. Toda classe é encontrada pelo mapa PSR-4 — pasta vira namespace, arquivo vira classe.

<?php
// ============ composer.json ============
// { "autoload": { "psr-4": { "MeuApp\\": "src/" } } }

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

namespace MeuApp\Routing;

interface RotaInterface
{
    public function executar(array $params): void;
}

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

namespace MeuApp\Routing;

final class RotaNaoEncontradaException extends \RuntimeException
{
}

final class Roteador
{
    /** @var array<string, array<string, RotaInterface>> método => padrão => rota */
    private array $rotas = [];

    public function registrar(string $metodo, string $padrao, RotaInterface $rota): void
    {
        $this->rotas[strtoupper($metodo)][$padrao] = $rota;
    }

    public function despachar(string $metodo, string $uri): void
    {
        // Fora a query string: /produtos/7?ref=x casa com /produtos/{id}
        $caminho = rtrim(parse_url($uri, PHP_URL_PATH) ?? '/', '/') ?: '/';

        foreach ($this->rotas[strtoupper($metodo)] ?? [] as $padrao => $rota) {
            $params = $this->casar($padrao, $caminho);

            if ($params !== null) {
                $rota->executar($params);
                return;
            }
        }

        throw new RotaNaoEncontradaException("Nenhuma rota para {$metodo} {$caminho}");
    }

    /**
     * Converte /produtos/{id} em regex e devolve os parâmetros casados.
     * @return array<string, string>|null  null = não casou
     */
    private function casar(string $padrao, string $caminho): ?array
    {
        // preg_quote primeiro, senão o ponto de ".json" viraria coringa.
        $regex = preg_replace(
            '/\\\\\{([a-zA-Z_]\w*)\\\\\}/',
            '(?<$1>[^/]+)',
            preg_quote(rtrim($padrao, '/') ?: '/', '#'),
        );

        if (!preg_match("#^{$regex}$#", $caminho, $m)) {
            return null;
        }

        // Só os grupos nomeados interessam.
        return array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
    }
}

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

namespace MeuApp\Controllers;

use MeuApp\Routing\RotaInterface;

final class HomeController implements RotaInterface
{
    public function executar(array $params): void
    {
        header('Content-Type: text/html; charset=utf-8');
        echo '<h1>Bem-vindo</h1>';
    }
}

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

namespace MeuApp\Controllers;

use MeuApp\Routing\RotaInterface;

final class ApiController implements RotaInterface
{
    public function executar(array $params): void
    {
        header('Content-Type: application/json; charset=utf-8');

        echo json_encode(
            ['produto' => (int) ($params['id'] ?? 0), 'ok' => true],
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE,
        );
    }
}

// ---------------------------------------- public/index.php
declare(strict_types=1);

// O ÚNICO require do projeto. Todo o resto vem do autoloader.
require __DIR__ . '/../vendor/autoload.php';

use MeuApp\Controllers\ApiController;
use MeuApp\Controllers\HomeController;
use MeuApp\Routing\RotaNaoEncontradaException;
use MeuApp\Routing\Roteador;

$roteador = new Roteador();
$roteador->registrar('GET', '/', new HomeController());
$roteador->registrar('GET', '/api/produtos/{id}', new ApiController());

try {
    $roteador->despachar($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
} catch (RotaNaoEncontradaException $e) {
    http_response_code(404);
    echo '404 — ', $e->getMessage();
}

// php -S localhost:8000 -t public
//   GET /                  → <h1>Bem-vindo</h1>
//   GET /api/produtos/7    → {"produto":7,"ok":true}
//   GET /qualquer-coisa    → 404
Comentários

Mais em PHP

Observer, Decorator e Strategy
Observer, Decorator e Strategy

Três padrões que aparecem em praticamente qualquer sistema: o Observer, que…

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

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

Herança, Interfaces e Traits
Herança, Interfaces e Traits

Herdar, assinar um contrato ou compor: três mecanismos que resolvem problemas…