APIs REST com PHP

APIs REST com PHP

Um roteador que traduz {param} em regex, um envelope JSON que o cliente consegue tratar num lugar só, handlers globais de erro e os códigos de status que evitam o 200 para tudo. REST em PHP puro, onde cada decisão que um framework esconderia fica à vista do leitor.
PHP

• • 26 min de leitura

REST (Representational State Transfer) é o estilo arquitetural dominante para APIs web. Uma API REST expõe recursos via URLs, usa os verbos HTTP (GET, POST, PUT, PATCH, DELETE) de acordo com a semântica da operação, retorna respostas em JSON com os códigos de status corretos, e é stateless — cada requisição carrega todas as informações necessárias para ser processada.

Construir APIs REST em PHP puro (sem framework) é o melhor exercício para entender o que frameworks fazem por baixo dos panos. Neste artigo construímos um mini-framework de roteamento, um sistema de resposta JSON padronizado, tratamento de erros estruturado, e aplicamos boas práticas de design de API que se traduzem diretamente para qualquer framework que você usar depois.

Estrutura de uma requisição HTTP

Antes de escrever código, é essencial entender como o PHP expõe uma requisição HTTP:

<?php
declare(strict_types=1);

// ── Método HTTP ────────────────────────────────────────────────────────
$metodo = $_SERVER['REQUEST_METHOD'];           // GET, POST, PUT, PATCH, DELETE

// ── URI e query string ─────────────────────────────────────────────────
$uri    = $_SERVER['REQUEST_URI'];              // /api/produtos?pagina=2&limite=20
$path   = parse_url($uri, PHP_URL_PATH);        // /api/produtos
parse_str($_SERVER['QUERY_STRING'] ?? '', $query); // ['pagina' => '2', 'limite' => '20']

// ── Headers ────────────────────────────────────────────────────────────
$contentType = $_SERVER['CONTENT_TYPE']         ?? '';
$accept      = $_SERVER['HTTP_ACCEPT']          ?? '';
$authHeader  = $_SERVER['HTTP_AUTHORIZATION']   ?? '';

// ── Corpo da requisição ────────────────────────────────────────────────
// Para POST com Content-Type: application/x-www-form-urlencoded → $_POST
// Para PUT, PATCH, DELETE e JSON → ler php://input
$corpoRaw = file_get_contents('php://input');
$corpo    = json_decode($corpoRaw, associative: true) ?? [];

// json_last_error() verifica se o JSON era válido
if ($corpoRaw !== '' && json_last_error() !== JSON_ERROR_NONE) {
    // Corpo inválido — retornar 400 Bad Request
}

Sistema de resposta JSON padronizado

Toda API precisa de um formato de resposta consistente. Um envelope padrão facilita o consumo pelo cliente e o debugging.

<?php
declare(strict_types=1);

class Resposta
{
    private function __construct() {}

    // ── Respostas de sucesso ──────────────────────────────────────────

    public static function ok(mixed $dados = null, string $mensagem = ''): never
    {
        self::enviar(200, ['sucesso' => true, 'dados' => $dados, 'mensagem' => $mensagem]);
    }

    public static function criado(mixed $dados = null, string $mensagem = 'Recurso criado.'): never
    {
        self::enviar(201, ['sucesso' => true, 'dados' => $dados, 'mensagem' => $mensagem]);
    }

    public static function semConteudo(): never
    {
        http_response_code(204);
        exit;
    }

    // ── Respostas de erro ─────────────────────────────────────────────

    public static function erro(
        int    $codigo,
        string $mensagem,
        array  $detalhes = [],
    ): never {
        self::enviar($codigo, [
            'sucesso'  => false,
            'erro'     => [
                'codigo'   => $codigo,
                'mensagem' => $mensagem,
                'detalhes' => $detalhes ?: null,
            ],
        ]);
    }

    public static function badRequest(string $mensagem = 'Requisição inválida.', array $detalhes = []): never
    {
        self::erro(400, $mensagem, $detalhes);
    }

    public static function naoAutorizado(string $mensagem = 'Autenticação necessária.'): never
    {
        self::erro(401, $mensagem);
    }

    public static function proibido(string $mensagem = 'Acesso negado.'): never
    {
        self::erro(403, $mensagem);
    }

    public static function naoEncontrado(string $mensagem = 'Recurso não encontrado.'): never
    {
        self::erro(404, $mensagem);
    }

    public static function erroInterno(string $mensagem = 'Erro interno do servidor.'): never
    {
        self::erro(500, $mensagem);
    }

    // ── Resposta paginada ─────────────────────────────────────────────

    public static function paginado(
        array $itens,
        int   $total,
        int   $pagina,
        int   $porPagina,
    ): never {
        $totalPaginas = (int) ceil($total / $porPagina);
        self::enviar(200, [
            'sucesso' => true,
            'dados'   => $itens,
            'paginacao' => [
                'total'        => $total,
                'por_pagina'   => $porPagina,
                'pagina_atual' => $pagina,
                'total_paginas'=> $totalPaginas,
                'tem_proxima'  => $pagina < $totalPaginas,
                'tem_anterior' => $pagina > 1,
            ],
        ]);
    }

    private static function enviar(int $codigo, array $corpo): never
    {
        http_response_code($codigo);
        header('Content-Type: application/json; charset=utf-8');
        header('X-Content-Type-Options: nosniff');
        echo json_encode($corpo, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
        exit;
    }
}

Router — roteamento com parâmetros dinâmicos

<?php
declare(strict_types=1);

class Router
{
    private array $rotas = [];

    public function get(string $padrao, callable $handler): void
    {
        $this->registrar('GET', $padrao, $handler);
    }

    public function post(string $padrao, callable $handler): void
    {
        $this->registrar('POST', $padrao, $handler);
    }

    public function put(string $padrao, callable $handler): void
    {
        $this->registrar('PUT', $padrao, $handler);
    }

    public function patch(string $padrao, callable $handler): void
    {
        $this->registrar('PATCH', $padrao, $handler);
    }

    public function delete(string $padrao, callable $handler): void
    {
        $this->registrar('DELETE', $padrao, $handler);
    }

    private function registrar(string $metodo, string $padrao, callable $handler): void
    {
        // Converte /produtos/{id} → regex /produtos/(?P<id>[^/]+).
        // O que está fora dos {} precisa ser escapado: sem preg_quote, o ponto
        // de uma rota como /feed.xml vira "qualquer caractere" e /feedaxml responde
        // igual — dois endereços para o mesmo recurso. Por isso o padrão é
        // quebrado nos placeholders, e só os pedaços literais passam pelo escape.
        $partes = preg_split('/(\{\w+\})/', $padrao, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
        $regex  = '';
        foreach ($partes as $parte) {
            $regex .= preg_match('/^\{(\w+)\}$/', $parte, $m)
                ? "(?P<{$m[1]}>[^/]+)"
                : preg_quote($parte, '#');
        }
        $this->rotas[] = [
            'metodo'  => $metodo,
            'padrao'  => "#^{$regex}$#",
            'handler' => $handler,
        ];
    }

    public function despachar(string $metodo, string $uri): void
    {
        $path = parse_url($uri, PHP_URL_PATH);

        foreach ($this->rotas as $rota) {
            if ($rota['metodo'] !== $metodo) continue;

            if (preg_match($rota['padrao'], $path, $matches)) {
                // Extrai apenas parâmetros nomeados (filtra índices numéricos)
                $params = array_filter(
                    $matches,
                    fn($k) => is_string($k),
                    ARRAY_FILTER_USE_KEY
                );
                // Tipagem automática, mas só do inteiro escrito de forma
                // canônica. Com is_numeric() a conversão é destrutiva e
                // silenciosa: "01310" viraria 1310, "1e3" viraria 1000, "3.9"
                // viraria 3 e um número acima de PHP_INT_MAX saturaria no
                // máximo. A API responderia sobre outro recurso sem avisar.
                $params = array_map(
                    fn($v) => ctype_digit($v) && $v === (string) (int) $v ? (int) $v : $v,
                    $params
                );
                ($rota['handler'])($params);
                return;
            }
        }

        Resposta::naoEncontrado("Rota não encontrada: {$metodo} {$path}");
    }
}

Controllers — organizando os handlers

<?php
declare(strict_types=1);

class ProdutoController
{
    public function __construct(
        private readonly ProdutoRepositorioInterface $repositorio,
    ) {}

    // GET /api/produtos
    public function listar(array $params): void
    {
        $pagina    = max(1, (int) ($_GET['pagina']   ?? 1));
        $porPagina = min(100, max(1, (int) ($_GET['limite'] ?? 20)));
        $categoria = $_GET['categoria'] ?? null;

        $resultado = $this->repositorio->buscarPaginado(
            pagina:    $pagina,
            porPagina: $porPagina,
            categoria: $categoria,
        );

        Resposta::paginado(
            itens:     array_map($this->serializar(...), $resultado['itens']),
            total:     $resultado['total'],
            pagina:    $pagina,
            porPagina: $porPagina,
        );
    }

    // GET /api/produtos/{id}
    public function mostrar(array $params): void
    {
        $produto = $this->repositorio->buscarPorId($params['id']);

        if ($produto === null) {
            Resposta::naoEncontrado("Produto #{$params['id']} não encontrado.");
        }

        Resposta::ok($this->serializar($produto));
    }

    // POST /api/produtos
    public function criar(array $params): void
    {
        $corpo = $this->lerCorpo();
        $erros = $this->validarCriacao($corpo);

        if (!empty($erros)) {
            Resposta::badRequest('Dados inválidos.', $erros);
        }

        $produto = $this->repositorio->salvar(new Produto(
            id:      0,
            nome:    trim($corpo['nome']),
            preco:   (float) $corpo['preco'],
            estoque: (int)   ($corpo['estoque'] ?? 0),
            ativo:   (bool)  ($corpo['ativo']   ?? true),
        ));

        Resposta::criado($this->serializar($produto), "Produto criado com sucesso.");
    }

    // PUT /api/produtos/{id}
    public function atualizar(array $params): void
    {
        $produto = $this->repositorio->buscarPorId($params['id']);
        if ($produto === null) Resposta::naoEncontrado();

        $corpo = $this->lerCorpo();
        $erros = $this->validarCriacao($corpo);
        if (!empty($erros)) Resposta::badRequest('Dados inválidos.', $erros);

        $atualizado = new Produto(
            id:      $produto->id,
            nome:    trim($corpo['nome']),
            preco:   (float) $corpo['preco'],
            estoque: (int)   ($corpo['estoque'] ?? $produto->estoque),
            ativo:   (bool)  ($corpo['ativo']   ?? $produto->ativo),
        );

        Resposta::ok($this->serializar($this->repositorio->salvar($atualizado)));
    }

    // PATCH /api/produtos/{id}
    public function atualizarParcial(array $params): void
    {
        $produto = $this->repositorio->buscarPorId($params['id']);
        if ($produto === null) Resposta::naoEncontrado();

        $corpo = $this->lerCorpo();

        // PATCH só atualiza os campos fornecidos — mantém o resto
        $atualizado = new Produto(
            id:      $produto->id,
            nome:    isset($corpo['nome'])    ? trim($corpo['nome'])    : $produto->nome,
            preco:   isset($corpo['preco'])   ? (float) $corpo['preco'] : $produto->preco,
            estoque: isset($corpo['estoque']) ? (int) $corpo['estoque'] : $produto->estoque,
            ativo:   isset($corpo['ativo'])   ? (bool) $corpo['ativo']  : $produto->ativo,
        );

        Resposta::ok($this->serializar($this->repositorio->salvar($atualizado)));
    }

    // DELETE /api/produtos/{id}
    public function deletar(array $params): void
    {
        $produto = $this->repositorio->buscarPorId($params['id']);
        if ($produto === null) Resposta::naoEncontrado();

        $this->repositorio->deletar($params['id']);
        Resposta::semConteudo();
    }

    // ── Helpers privados ─────────────────────────────────────────────

    private function lerCorpo(): array
    {
        $raw = file_get_contents('php://input');
        if (empty($raw)) return [];

        $dados = json_decode($raw, associative: true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            Resposta::badRequest('JSON inválido: ' . json_last_error_msg());
        }
        return $dados ?? [];
    }

    private function validarCriacao(array $dados): array
    {
        $erros = [];
        if (empty($dados['nome'])) {
            $erros['nome'] = 'O nome é obrigatório.';
        } elseif (strlen($dados['nome']) > 200) {
            $erros['nome'] = 'O nome deve ter no máximo 200 caracteres.';
        }
        if (!isset($dados['preco']) || !is_numeric($dados['preco']) || $dados['preco'] < 0) {
            $erros['preco'] = 'O preço deve ser um número positivo.';
        }
        return $erros;
    }

    private function serializar(Produto $p): array
    {
        return [
            'id'       => $p->id,
            'nome'     => $p->nome,
            'preco'    => $p->preco,
            'estoque'  => $p->estoque,
            'ativo'    => $p->ativo,
        ];
    }
}

Tratamento global de erros e ponto de entrada

<?php
declare(strict_types=1);

// public/index.php — ponto de entrada único

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

// Headers CORS para APIs consumidas por outros domínios. O curinga serve a
// API pública, que autentica por token no header Authorization. Ele não serve
// a quem autentica por cookie: com "*" o navegador se recusa a enviar credencial,
// e combinar o curinga com Access-Control-Allow-Credentials: true faz o browser
// descartar a resposta. Nesse caso, ecoe a origem exata vinda de uma lista.
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

// Preflight CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// Handler global de exceções não capturadas
set_exception_handler(function (\Throwable $e) {
    $isDev = getenv('APP_ENV') === 'development';

    error_log($e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());

    Resposta::erroInterno(
        $isDev ? $e->getMessage() : 'Erro interno do servidor.'
    );
});

// Handler de erros PHP como exceções
set_error_handler(function (int $severity, string $msg, string $file, int $line): bool {
    if (!(error_reporting() & $severity)) return false;
    throw new \ErrorException($msg, 0, $severity, $file, $line);
});

// Conexão e dependências
$pdo        = criarConexao(/* ... */);
$repositorio = new ProdutoRepositorioPDO($pdo);
$controller  = new ProdutoController($repositorio);

// Definição das rotas
$router = new Router();

$router->get   ('/api/produtos',      [$controller, 'listar']);
$router->get   ('/api/produtos/{id}', [$controller, 'mostrar']);
$router->post  ('/api/produtos',      [$controller, 'criar']);
$router->put   ('/api/produtos/{id}', [$controller, 'atualizar']);
$router->patch ('/api/produtos/{id}', [$controller, 'atualizarParcial']);
$router->delete('/api/produtos/{id}', [$controller, 'deletar']);

// Despacha a requisição
$router->despachar(
    $_SERVER['REQUEST_METHOD'],
    $_SERVER['REQUEST_URI'],
);

Boas práticas de design de API REST

# Nomes de recursos sempre no plural e em substantivos
GET  /api/produtos          ✅   GET  /api/buscarProdutos    ❌
GET  /api/produtos/42       ✅   GET  /api/getProduto/42     ❌
POST /api/produtos          ✅   POST /api/criarProduto      ❌

# Relacionamentos como sub-recursos
GET  /api/pedidos/7/itens       # itens do pedido 7
GET  /api/usuarios/3/pedidos    # pedidos do usuário 3

# Ações que não mapeiam para CRUD usam verbos como sufixo
POST /api/pedidos/7/cancelar    # cancelar pedido 7
POST /api/usuarios/3/ativar     # ativar usuário 3

# Versionamento na URL — protege clientes existentes
GET  /api/v1/produtos
GET  /api/v2/produtos           # nova versão com breaking changes

# Filtros, ordenação e paginação via query string
GET  /api/produtos?categoria=eletronicos&preco_min=100&preco_max=2000
GET  /api/produtos?ordenar=preco&direcao=asc
GET  /api/produtos?pagina=2&limite=20

# Códigos de status HTTP corretos — não retornar 200 para tudo
200 OK              → GET bem-sucedido, UPDATE bem-sucedido
201 Created         → POST que criou um recurso
204 No Content      → DELETE bem-sucedido, sem corpo de resposta
400 Bad Request     → dados inválidos, JSON malformado
401 Unauthorized    → sem autenticação
403 Forbidden       → autenticado mas sem permissão
404 Not Found       → recurso não existe
409 Conflict        → conflito de estado (email duplicado)
422 Unprocessable   → validação falhou (alternativa ao 400)
500 Internal Error  → erro não tratado no servidor

Escrever a API sem framework tem um efeito colateral útil: cada decisão que o Laravel ou o Slim tomam por você aparece à vista. O envelope de resposta é uma convenção sua, não uma lei do REST — mas escolher uma e não variar dela é o que permite ao cliente tratar sucesso e erro num lugar só. O mesmo vale para os códigos de status: devolver 200 com "sucesso": false no corpo obriga todo consumidor a ler o JSON para saber se a chamada deu certo.

O roteador é onde as arestas se escondem. Um padrão que vai direto para o regex sem escape transforma o ponto de /feed.xml em curinga, e uma conversão baseada em is_numeric() engole zero à esquerda e notação científica sem dizer nada. Nos dois casos a requisição é atendida, o status é 200, e o recurso devolvido é o errado — que é a pior forma de um bug se apresentar.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Estenda o Router para suportar middlewares: método middleware(callable ...$mws) que envolve o handler — cada middleware recebe ($params, $proximo) e pode chamar $proximo($params) para continuar ou retornar uma resposta de erro.

Ver resposta

✓ Resposta: A construção de trás para frente (array_reverse) é o que faz o primeiro middleware registrado ser o mais externo. E a regra de ouro da cebola: não chamar $proximo é o que aborta a requisição — é assim que o middleware de autenticação barra sem lançar exceção.

<?php

declare(strict_types=1);

final class Rota
{
    /** @var callable[] */
    private array $middlewares = [];

    public function __construct(
        public readonly string $metodo,
        public readonly string $path,
        public readonly mixed $handler,
    ) {}

    public function middleware(callable ...$mws): static
    {
        foreach ($mws as $mw) {
            $this->middlewares[] = $mw;
        }
        return $this;
    }

    /**
     * Monta a cebola: o último middleware fica mais perto do handler.
     * Construindo de trás para frente, cada camada recebe como $proximo a
     * cadeia que já foi montada — é o mesmo mecanismo do pipeline do Laravel.
     */
    public function executar(array $params): mixed
    {
        $proximo = fn (array $p): mixed => ($this->handler)($p);

        foreach (array_reverse($this->middlewares) as $mw) {
            $anterior = $proximo;
            $proximo = fn (array $p): mixed => $mw($p, $anterior);
        }

        return $proximo($params);
    }
}

final class Router
{
    /** @var Rota[] */
    private array $rotas = [];

    /** @var callable[] aplicados a todas as rotas */
    private array $globais = [];

    public function middlewareGlobal(callable ...$mws): void
    {
        foreach ($mws as $mw) {
            $this->globais[] = $mw;
        }
    }

    public function get(string $path, callable $handler): Rota
    {
        return $this->adicionar('GET', $path, $handler);
    }

    public function post(string $path, callable $handler): Rota
    {
        return $this->adicionar('POST', $path, $handler);
    }

    public function put(string $path, callable $handler): Rota
    {
        return $this->adicionar('PUT', $path, $handler);
    }

    public function delete(string $path, callable $handler): Rota
    {
        return $this->adicionar('DELETE', $path, $handler);
    }

    private function adicionar(string $metodo, string $path, callable $handler): Rota
    {
        $rota = new Rota($metodo, $path, $handler);
        $this->rotas[] = $rota;

        return $rota;   // devolve para permitir ->middleware(...) encadeado
    }

    public function despachar(string $metodo, string $uri): mixed
    {
        $caminho = rtrim(parse_url($uri, PHP_URL_PATH) ?? '/', '/') ?: '/';

        foreach ($this->rotas as $rota) {
            if ($rota->metodo !== strtoupper($metodo)) {
                continue;
            }

            $regex = '#^' . preg_replace(
                '/\\\\\{(\w+)\\\\\}/', '(?<$1>[^/]+)', preg_quote($rota->path, '#')
            ) . '$#';

            if (preg_match($regex, $caminho, $m)) {
                $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);

                return $rota->middleware(...$this->globais)->executar($params);
            }
        }

        http_response_code(404);
        return ['erro' => 'nao_encontrado'];
    }
}

// ---------- Middlewares ---------------------------------------------------
$autenticar = static function (array $params, callable $proximo): mixed {
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

    if (!str_starts_with($header, 'Bearer ')) {
        http_response_code(401);
        // Não chamar $proximo é o que interrompe a cadeia.
        return ['erro' => 'nao_autenticado'];
    }

    $params['usuario_id'] = 7;   // viria da validação do token

    return $proximo($params);
};

$logar = static function (array $params, callable $proximo): mixed {
    $inicio = hrtime(true);

    $resposta = $proximo($params);   // antes e depois: a cebola permite os dois

    error_log(sprintf('%s %s — %.2f ms',
        $_SERVER['REQUEST_METHOD'] ?? 'CLI', $_SERVER['REQUEST_URI'] ?? '/',
        (hrtime(true) - $inicio) / 1_000_000));

    return $resposta;
};

$json = static function (array $params, callable $proximo): mixed {
    header('Content-Type: application/json; charset=utf-8');
    return $proximo($params);
};

// ---------- Uso -----------------------------------------------------------
$router = new Router();
$router->middlewareGlobal($json, $logar);

$router->get('/api/perfil', fn (array $p): array => ['usuario' => $p['usuario_id']])
       ->middleware($autenticar);

echo json_encode($router->despachar('GET', '/api/perfil'));

Exercício 2

Implemente um UsuarioController completo com as rotas: GET /api/usuarios, GET /api/usuarios/{id}, POST /api/usuarios, PUT /api/usuarios/{id}, DELETE /api/usuarios/{id}. Inclua validação de email único no POST.

Ver resposta

✓ Resposta: Os status contam história: 201 com Location ao criar, 204 sem corpo ao excluir, 422 para validação e 409 para conflito. E note a corrida no e-mail único — a validação antecipada melhora a mensagem, mas quem garante é o UNIQUE.

<?php

declare(strict_types=1);

final class Resposta
{
    public static function json(mixed $dados, int $status = 200): array
    {
        http_response_code($status);
        return ['status' => $status, 'corpo' => $dados];
    }

    public static function erro(string $codigo, string $msg, int $status, array $extra = []): array
    {
        return self::json(['erro' => $codigo, 'mensagem' => $msg] + $extra, $status);
    }
}

final class UsuarioController
{
    public function __construct(private readonly PDO $pdo) {}

    /** GET /api/usuarios */
    public function index(array $params): array
    {
        $pagina = max(1, (int) ($_GET['pagina'] ?? 1));
        $porPagina = min(100, max(1, (int) ($_GET['por_pagina'] ?? 20)));

        $total = (int) $this->pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn();

        $stmt = $this->pdo->prepare(
            'SELECT id, nome, email, criado_em FROM usuarios
             ORDER BY id LIMIT :limite OFFSET :offset'
        );
        $stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
        $stmt->bindValue(':offset', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
        $stmt->execute();

        return Resposta::json([
            'dados' => $stmt->fetchAll(PDO::FETCH_ASSOC),
            'meta'  => ['pagina' => $pagina, 'total' => $total,
                        'paginas' => (int) ceil($total / $porPagina)],
        ]);
    }

    /** GET /api/usuarios/{id} */
    public function mostrar(array $params): array
    {
        $usuario = $this->buscar((int) $params['id']);

        if ($usuario === null) {
            return Resposta::erro('nao_encontrado', 'Usuário não encontrado.', 404);
        }

        return Resposta::json($usuario);
    }

    /** POST /api/usuarios */
    public function criar(array $params): array
    {
        $dados = $this->corpoJson();

        $erros = $this->validar($dados);

        if ($erros !== []) {
            // 422: sintaxe correta, conteúdo inválido. Diferente de 400.
            return Resposta::erro('validacao', 'Dados inválidos.', 422, ['campos' => $erros]);
        }

        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO usuarios (nome, email, senha_hash, uuid)
                 VALUES (:nome, :email, :senha, :uuid)'
            );
            $stmt->execute([
                ':nome'  => trim($dados['nome']),
                ':email' => mb_strtolower(trim($dados['email'])),
                ':senha' => password_hash($dados['senha'], PASSWORD_DEFAULT),
                ':uuid'  => bin2hex(random_bytes(16)),
            ]);
        } catch (PDOException $e) {
            // A checagem antecipada do e-mail tem corrida: duas requisições
            // simultâneas passam as duas. Quem garante de verdade é o UNIQUE
            // do banco — por isso o catch do 23000 também existe.
            if ($e->getCode() === '23000') {
                return Resposta::erro('email_duplicado', 'E-mail já cadastrado.', 409);
            }
            throw $e;
        }

        $id = (int) $this->pdo->lastInsertId();
        header('Location: /api/usuarios/' . $id);

        return Resposta::json($this->buscar($id), 201);
    }

    /** PUT /api/usuarios/{id} */
    public function atualizar(array $params): array
    {
        $id = (int) $params['id'];

        if ($this->buscar($id) === null) {
            return Resposta::erro('nao_encontrado', 'Usuário não encontrado.', 404);
        }

        $dados = $this->corpoJson();
        $erros = $this->validar($dados, exigirSenha: false, ignorarId: $id);

        if ($erros !== []) {
            return Resposta::erro('validacao', 'Dados inválidos.', 422, ['campos' => $erros]);
        }

        $stmt = $this->pdo->prepare(
            'UPDATE usuarios SET nome = :nome, email = :email WHERE id = :id'
        );
        $stmt->execute([
            ':nome'  => trim($dados['nome']),
            ':email' => mb_strtolower(trim($dados['email'])),
            ':id'    => $id,
        ]);

        return Resposta::json($this->buscar($id));
    }

    /** DELETE /api/usuarios/{id} */
    public function remover(array $params): array
    {
        $stmt = $this->pdo->prepare('DELETE FROM usuarios WHERE id = :id');
        $stmt->execute([':id' => (int) $params['id']]);

        if ($stmt->rowCount() === 0) {
            return Resposta::erro('nao_encontrado', 'Usuário não encontrado.', 404);
        }

        // 204: sucesso sem corpo. Não devolva JSON aqui.
        http_response_code(204);
        return ['status' => 204, 'corpo' => null];
    }

    private function buscar(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nome, email, criado_em FROM usuarios WHERE id = :id'
        );
        $stmt->execute([':id' => $id]);

        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    private function corpoJson(): array
    {
        $bruto = file_get_contents('php://input');

        try {
            return json_decode($bruto ?: '{}', true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return [];
        }
    }

    private function validar(array $d, bool $exigirSenha = true, ?int $ignorarId = null): array
    {
        $erros = [];

        if (trim($d['nome'] ?? '') === '') {
            $erros['nome'] = 'Obrigatório.';
        }

        $email = trim($d['email'] ?? '');

        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $erros['email'] = 'E-mail inválido.';
        } else {
            $sql = 'SELECT COUNT(*) FROM usuarios WHERE email = :email';
            $bind = [':email' => mb_strtolower($email)];

            if ($ignorarId !== null) {
                $sql .= ' AND id <> :id';   // no update, ele mesmo não conta
                $bind[':id'] = $ignorarId;
            }

            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($bind);

            if ((int) $stmt->fetchColumn() > 0) {
                $erros['email'] = 'E-mail já cadastrado.';
            }
        }

        if ($exigirSenha && mb_strlen($d['senha'] ?? '') < 8) {
            $erros['senha'] = 'Mínimo de 8 caracteres.';
        }

        return $erros;
    }
}

$router->get('/api/usuarios',      fn ($p) => $controller->index($p));
$router->get('/api/usuarios/{id}', fn ($p) => $controller->mostrar($p));
$router->post('/api/usuarios',     fn ($p) => $controller->criar($p));
$router->put('/api/usuarios/{id}', fn ($p) => $controller->atualizar($p));
$router->delete('/api/usuarios/{id}', fn ($p) => $controller->remover($p));

Exercício 3

Adicione suporte a sub-recursos: GET /api/usuarios/{id}/pedidos que retorna os pedidos de um usuário específico, com paginação. O controller deve verificar se o usuário existe antes de buscar os pedidos.

Ver resposta

✓ Resposta: Verificar o pai antes é o que dá significado às respostas: 404 quando o usuário não existe, 200 com lista vazia quando existe mas não tem pedidos. Sem isso, o cliente da API não consegue distinguir "id errado" de "nenhum pedido ainda".

<?php

declare(strict_types=1);

final class PedidoController
{
    public function __construct(private readonly PDO $pdo) {}

    /** GET /api/usuarios/{id}/pedidos?pagina=1&por_pagina=20 */
    public function porUsuario(array $params): array
    {
        $usuarioId = (int) $params['id'];

        // Verificar o pai PRIMEIRO muda a semântica da resposta:
        // usuário inexistente → 404
        // usuário sem pedidos → 200 com lista vazia
        // Sem essa checagem, os dois casos devolveriam a mesma coisa.
        if (!$this->usuarioExiste($usuarioId)) {
            return Resposta::erro('usuario_nao_encontrado',
                                  "Usuário {$usuarioId} não existe.", 404);
        }

        $pagina = max(1, (int) ($_GET['pagina'] ?? 1));
        $porPagina = min(100, max(1, (int) ($_GET['por_pagina'] ?? 20)));

        $filtros = ['p.usuario_id = :usuario'];
        $bind = [':usuario' => $usuarioId];

        if (($status = $_GET['status'] ?? null) !== null) {
            $filtros[] = 'p.status = :status';
            $bind[':status'] = $status;
        }

        $where = implode(' AND ', $filtros);

        $stmtTotal = $this->pdo->prepare("SELECT COUNT(*) FROM pedidos p WHERE {$where}");
        $stmtTotal->execute($bind);
        $total = (int) $stmtTotal->fetchColumn();

        $stmt = $this->pdo->prepare(
            "SELECT p.id, p.status, p.total, p.criado_em,
                    COUNT(i.id) AS itens
               FROM pedidos p
               LEFT JOIN pedido_itens i ON i.pedido_id = p.id
              WHERE {$where}
           GROUP BY p.id, p.status, p.total, p.criado_em
           ORDER BY p.criado_em DESC
              LIMIT :limite OFFSET :offset"
        );

        foreach ($bind as $chave => $valor) {
            $stmt->bindValue($chave, $valor);
        }
        $stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
        $stmt->bindValue(':offset', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
        $stmt->execute();

        $paginas = (int) ceil($total / $porPagina);

        return Resposta::json([
            'dados' => $stmt->fetchAll(PDO::FETCH_ASSOC),
            'meta'  => [
                'usuario_id' => $usuarioId,
                'pagina'     => $pagina,
                'por_pagina' => $porPagina,
                'total'      => $total,
                'paginas'    => $paginas,
            ],
            'links' => [
                'self'     => "/api/usuarios/{$usuarioId}/pedidos?pagina={$pagina}",
                'proxima'  => $pagina < $paginas
                    ? "/api/usuarios/{$usuarioId}/pedidos?pagina=" . ($pagina + 1) : null,
                'anterior' => $pagina > 1
                    ? "/api/usuarios/{$usuarioId}/pedidos?pagina=" . ($pagina - 1) : null,
            ],
        ]);
    }

    private function usuarioExiste(int $id): bool
    {
        $stmt = $this->pdo->prepare('SELECT 1 FROM usuarios WHERE id = :id LIMIT 1');
        $stmt->execute([':id' => $id]);

        return $stmt->fetchColumn() !== false;
    }
}

$router->get('/api/usuarios/{id}/pedidos', fn ($p) => $pedidoController->porUsuario($p))
       ->middleware($autenticar);

// GET /api/usuarios/7/pedidos?pagina=1
// { "dados": [...], "meta": {...}, "links": { "proxima": "...?pagina=2" } }
//
// GET /api/usuarios/999/pedidos
// 404 { "erro": "usuario_nao_encontrado" }

Exercício 4

Implemente rate limiting simples: use APCu ou um arquivo JSON para registrar o número de requisições por IP na última hora. Se ultrapassar 100 requisições, retornar 429 Too Many Requests com header Retry-After.

Ver resposta

✓ Resposta: Confiar em X-Forwarded-For sem validar é o furo comum: o cliente pode forjar o header e trocar de "IP" a cada requisição. Só aceite esse header vindo de um proxy seu, e nunca guarde o IP em texto puro num rate limiter de longa duração — sha1 na chave já ajuda.

<?php

declare(strict_types=1);

interface LimitadorInterface
{
    /** @return array{permitido: bool, restante: int, retry_after: int} */
    public function verificar(string $chave, int $limite, int $janelaSegundos): array;
}

final class LimitadorApcu implements LimitadorInterface
{
    public function verificar(string $chave, int $limite, int $janela): array
    {
        $agora = time();
        $chave = 'rl:' . sha1($chave);

        // Janela deslizante: guarda os timestamps e descarta os que saíram
        // da janela. Mais justo que o contador fixo, onde o cliente pode
        // gastar o limite todo no fim de uma janela e de novo no começo da
        // seguinte — o dobro em poucos segundos.
        $registros = apcu_fetch($chave) ?: [];
        $registros = array_values(array_filter(
            $registros,
            static fn(int $t): bool => $t > $agora - $janela,
        ));

        if (count($registros) >= $limite) {
            $maisAntigo = min($registros);

            return [
                'permitido'   => false,
                'restante'    => 0,
                'retry_after' => max(1, ($maisAntigo + $janela) - $agora),
            ];
        }

        $registros[] = $agora;
        apcu_store($chave, $registros, $janela);

        return [
            'permitido'   => true,
            'restante'    => $limite - count($registros),
            'retry_after' => 0,
        ];
    }
}

/** Alternativa sem APCu, para hospedagem compartilhada. */
final class LimitadorArquivo implements LimitadorInterface
{
    public function __construct(private readonly string $diretorio = '/tmp/ratelimit') {}

    public function verificar(string $chave, int $limite, int $janela): array
    {
        if (!is_dir($this->diretorio)) {
            mkdir($this->diretorio, 0700, true);
        }

        $arquivo = $this->diretorio . '/' . sha1($chave) . '.json';
        $agora = time();

        $handle = fopen($arquivo, 'c+');

        // LOCK_EX é o que impede duas requisições simultâneas de lerem o
        // mesmo contador e gravarem por cima uma da outra.
        flock($handle, LOCK_EX);

        $conteudo = stream_get_contents($handle);
        $registros = $conteudo ? (json_decode($conteudo, true) ?: []) : [];

        $registros = array_values(array_filter(
            $registros, static fn(int $t): bool => $t > $agora - $janela
        ));

        $permitido = count($registros) < $limite;

        if ($permitido) {
            $registros[] = $agora;
        }

        ftruncate($handle, 0);
        rewind($handle);
        fwrite($handle, json_encode($registros));

        flock($handle, LOCK_UN);
        fclose($handle);

        return [
            'permitido'   => $permitido,
            'restante'    => max(0, $limite - count($registros)),
            'retry_after' => $permitido ? 0 : max(1, (min($registros) + $janela) - $agora),
        ];
    }
}

// ---------- Middleware ----------------------------------------------------
function limitarTaxa(LimitadorInterface $limitador, int $limite = 100, int $janela = 3600): callable
{
    return static function (array $params, callable $proximo) use ($limitador, $limite, $janela): mixed {
        $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'desconhecido';
        $ip = trim(explode(',', $ip)[0]);   // o primeiro da cadeia de proxies

        $r = $limitador->verificar($ip, $limite, $janela);

        header('X-RateLimit-Limit: ' . $limite);
        header('X-RateLimit-Remaining: ' . $r['restante']);

        if (!$r['permitido']) {
            http_response_code(429);
            header('Retry-After: ' . $r['retry_after']);

            return [
                'erro'     => 'muitas_requisicoes',
                'mensagem' => "Limite de {$limite} requisições por hora excedido.",
                'tente_em' => $r['retry_after'] . 's',
            ];
        }

        return $proximo($params);
    };
}

$router->middlewareGlobal(limitarTaxa(new LimitadorApcu(), limite: 100, janela: 3600));

Exercício 5

Desafio: crie um sistema de versionamento de API: rotas registradas como /api/v1/produtos e /api/v2/produtos podem coexistir, com controllers diferentes. Implemente uma classe ApiVersion que lê o prefixo de versão da URL e instancia o controller correto via array de mapeamento.

Ver resposta

✓ Resposta: Repare que usuarios aponta para a mesma classe nas duas versões: versionar não obriga a duplicar tudo, só o que mudou de contrato. E os cabeçalhos Deprecation/Sunset são o que permite desligar a v1 um dia sem pegar ninguém de surpresa.

<?php

declare(strict_types=1);

enum VersaoApi: string
{
    case V1 = 'v1';
    case V2 = 'v2';

    public function depreciada(): bool
    {
        return $this === self::V1;
    }

    public function sunsetEm(): ?string
    {
        return $this === self::V1 ? '2027-01-01' : null;
    }
}

final class ApiVersion
{
    /** @var array<string, array<string, class-string>> versão => recurso => controller */
    private const MAPA = [
        'v1' => [
            'produtos' => ProdutoControllerV1::class,
            'usuarios' => UsuarioControllerV1::class,
        ],
        'v2' => [
            'produtos' => ProdutoControllerV2::class,
            // usuarios não mudou na v2: reaproveita o da v1.
            'usuarios' => UsuarioControllerV1::class,
        ],
    ];

    public const PADRAO = VersaoApi::V2;

    /** Lê /api/v1/produtos → [VersaoApi::V1, 'produtos', resto] */
    public static function daUri(string $uri): array
    {
        $caminho = trim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
        $partes = explode('/', $caminho);

        // Espera api/{versao}/{recurso}/...
        if (($partes[0] ?? '') !== 'api') {
            throw new InvalidArgumentException('URI fora do prefixo /api.');
        }

        $versao = VersaoApi::tryFrom($partes[1] ?? '');

        if ($versao === null) {
            // Sem versão na URL: assume a padrão e desloca os índices.
            $versao = self::PADRAO;
            $recurso = $partes[1] ?? '';
            $resto = array_slice($partes, 2);
        } else {
            $recurso = $partes[2] ?? '';
            $resto = array_slice($partes, 3);
        }

        return [$versao, $recurso, $resto];
    }

    public static function controller(VersaoApi $versao, string $recurso): object
    {
        $classe = self::MAPA[$versao->value][$recurso]
            ?? throw new RuntimeException(
                "Recurso '{$recurso}' não existe na {$versao->value}."
            );

        return new $classe();
    }

    /** Avisa o cliente que a versão vai sair do ar (RFC 8594). */
    public static function cabecalhos(VersaoApi $versao): void
    {
        header('X-Api-Version: ' . $versao->value);

        if ($versao->depreciada()) {
            header('Deprecation: true');
            header('Sunset: ' . $versao->sunsetEm());
            header('Link: </api/v2>; rel="successor-version"');
        }
    }
}

// ---------- Os controllers convivem --------------------------------------
final class ProdutoControllerV1
{
    public function index(array $params): array
    {
        // Formato antigo: lista crua, preço como float.
        return [
            ['id' => 1, 'nome' => 'Teclado', 'preco' => 349.90],
        ];
    }
}

final class ProdutoControllerV2
{
    public function index(array $params): array
    {
        // Formato novo: envelope com meta, preço em centavos (sem float),
        // e campos renomeados. Quebrar contrato é o motivo de existir a v2.
        return [
            'dados' => [
                ['id' => 1, 'titulo' => 'Teclado', 'preco_centavos' => 34990,
                 'moeda' => 'BRL'],
            ],
            'meta' => ['versao' => 'v2', 'total' => 1],
        ];
    }
}

final class UsuarioControllerV1
{
    public function index(array $params): array
    {
        return [['id' => 7, 'nome' => 'Ana']];
    }
}

// ---------- Despacho ------------------------------------------------------
try {
    [$versao, $recurso, $resto] = ApiVersion::daUri($_SERVER['REQUEST_URI'] ?? '/api/v2/produtos');

    ApiVersion::cabecalhos($versao);

    $controller = ApiVersion::controller($versao, $recurso);

    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($controller->index(['resto' => $resto]), JSON_UNESCAPED_UNICODE);

} catch (RuntimeException $e) {
    http_response_code(404);
    echo json_encode(['erro' => 'recurso_desconhecido', 'mensagem' => $e->getMessage()]);
}

// GET /api/v1/produtos → [{"id":1,"nome":"Teclado","preco":349.9}]
//   Deprecation: true / Sunset: 2027-01-01
// GET /api/v2/produtos → {"dados":[{"id":1,"titulo":"Teclado",...}],"meta":{...}}
Comentários

Mais em PHP

Upload de Arquivos
Upload de Arquivos

Validar pelo conteúdo e não pelo que o navegador declara, gerar o nome no…

Configurando o Ambiente de Desenvolvimento
Configurando o Ambiente de Desenvolvimento

Instalar o PHP é a parte fácil; o que decide se o ambiente vai ajudar ou…

Arrays em Profundidade
Arrays em Profundidade

O array do PHP é uma estrutura só que faz o papel de lista, mapa e registro —…