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>[^/]+)
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $padrao);
$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 de IDs numéricos
$params = array_map(
fn($v) => is_numeric($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
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
Resumo do artigo
| Conceito | O que aprendemos |
|---|---|
| Estrutura HTTP | $_SERVER, php://input, parse_url(), json_decode() |
| Resposta padronizada | Envelope JSON com sucesso, dados, erro, paginacao |
| Router | Converte {param} em regex, extrai parâmetros nomeados |
| Controllers | Separa lógica de rota da lógica de negócio |
| PUT vs PATCH | PUT substitui o recurso completo; PATCH atualiza campos específicos |
| CORS | Headers Access-Control-Allow-* + tratamento de preflight OPTIONS |
| Tratamento de erros | set_exception_handler() + set_error_handler() globais |
| Boas práticas REST | Nomes plurais, verbos HTTP corretos, status HTTP semânticos |
Exercício da semana
-
Estenda o
Routerpara suportar middlewares: métodomiddleware(callable ...$mws)que envolve o handler — cada middleware recebe($params, $proximo)e pode chamar$proximo($params)para continuar ou retornar uma resposta de erro. -
Implemente um
UsuarioControllercompleto 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. -
Adicione suporte a sub-recursos:
GET /api/usuarios/{id}/pedidosque 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. -
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 Requestscom headerRetry-After. -
Desafio: crie um sistema de versionamento de API: rotas registradas como
/api/v1/produtose/api/v2/produtospodem coexistir, com controllers diferentes. Implemente uma classeApiVersionque lê o prefixo de versão da URL e instancia o controller correto via array de mapeamento.
Referências
- MDN — HTTP Methods: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Methods
- MDN — HTTP Status Codes: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Status
- REST API Design Best Practices — Fielding dissertation: https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
- PHP Manual — php://input: https://www.php.net/manual/pt_BR/wrappers.php.php
- JSON:API specification: https://jsonapi.org