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 comoDateTime,InvalidArgumentExceptionestdClassvivem no namespace global (sem prefixo). Dentro de um arquivo com namespace, você precisa ou importá-las comuseou prefixá-las com\— por exemplo\DateTime. Odeclare(strict_types=1)deve vir antes donamespace.
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\ClassNamemapeia paravendor_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
Ocomposer.lockregistra as versões exatas instaladas. Quando outro desenvolvedor (ou o servidor de deploy) rodacomposer install, ele recebe exatamente as mesmas versões — não as mais recentes compatíveis com a constraint. Sempre commite ocomposer.lock. Só rodecomposer updatequando 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
- Namespaces — Manual oficial do PHP — Documentação completa de namespaces, resolução de nomes, importação com
usee namespaces aninhados. - PSR-4: Autoloader — PHP-FIG — A especificação oficial da PSR-4. Documento curto e preciso que descreve a regra de mapeamento namespace → arquivo com exemplos.
- PSR-1: Basic Coding Standard — PHP-FIG — Padrão básico que define: um arquivo uma classe, capitalização de nomes, uso de
declare(strict_types=1)e outras convenções fundamentais. - Composer — Documentação oficial — Documentação completa do Composer: instalação, comandos,
composer.json, estratégias de versionamento, scripts e otimização para produção. - Packagist — repositório central de pacotes PHP — O repositório oficial de pacotes PHP. Onde o Composer busca dependências por padrão — qualquer pacote com
composer require nome/pacotevem daqui. - PHP: The Right Way — Namespaces — Visão geral de boas práticas com namespaces, autoloading e uso de Composer no contexto do desenvolvimento PHP moderno.
- Semantic Versioning — semver.org — A especificação de versionamento semântico em português. Explica a semântica de MAJOR.MINOR.PATCH e quando cada número deve ser incrementado.
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