PHP

APIs REST com PHP Já leu

11 min de leitura

APIs REST com PHP
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, reto

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

  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.

  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.

  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.

  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.

  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.


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
Comentários

Mais em PHP

Formulários HTML e PHP
Formulários HTML e PHP

Formulários são a porta de entrada dos dados do usuário para o servidor. Tudo...

Observer, Decorator e Strategy
Observer, Decorator e Strategy

No artigo anterior estudamos três padrões criacionais — como construir objeto...

Namespaces, Autoloading, PSR-4 e Composer
Namespaces, Autoloading, PSR-4 e Composer

At&eacute; agora todos os exemplos couberam em um &uacute;nico arquivo. Na pr...