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.php ≠ Produto.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

Testes Automatizados com PHPUnit e Pest
Testes Automatizados com PHPUnit e Pest

PHPUnit e Pest lado a lado, mocks, testes de integração com banco e testes de…

Interfaces Avançadas
Interfaces Avançadas

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

Segurança Avançada em PHP
Segurança Avançada em PHP

Criptografia simétrica e assimétrica, dados pessoais sob a LGPD, headers HTTP…