PHP MVCDOCUMENTAÇÃO
v2.0.0

API HTTP: guia completo

Do primeiro endpoint à autenticação Bearer, validação, Resources, persistência, erros e testes.

Este guia parte do que está realmente implementado no projeto. Ele separa os endpoints já ativos dos exemplos que você criará ao acompanhar o tutorial; nenhum exemplo novo passa a existir até os arquivos e rotas serem adicionados.

API de entrada ≠ cliente de saída. Esta página ensina a receber chamadas HTTP em /api/v1. Para o PHP consumir um serviço externo, use ApiClient e ApiRequest; são fluxos diferentes.

1. O que é a API e como ela funciona

Uma API HTTP é um contrato entre cliente e servidor: o cliente envia método, caminho, cabeçalhos e, quando necessário, um corpo; o servidor valida a entrada e responde com status HTTP e representação dos dados. Na API própria deste MVC, a representação normal é JSON e a versão ativa está sob /api/v1.

O caminho real de uma chamada é: public/index.php → bootstrap/app.php → Framework\Core\Application::run() → inclusão de routes/web.php e de cada routes/api*.php → Router::dispatch() → middlewares da rota/grupo → Controller → Request/FormRequest/Validator → Model/Repository/Service → Resource → ApiResponse. O bootstrap também carrega Composer, .env, sessão e cabeçalhos globais; a autenticação Bearer da API, porém, é verificada pelo middleware próprio e não depende do cookie de login Web.

O arquivo routes/api.php registra hoje estas rotas ativas (os comentários de CRUD futuro não são endpoints):

Método e caminhoAcessoComportamento atual
POST /api/v1/auth/loginPúblico; limite adicional de loginValida e-mail/senha, emite token Bearer e devolve UserResource.
POST /api/v1/auth/logoutBearer válidoRevoga o token da requisição.
GET /api/v1/auth/meBearer válidoDevolve o usuário corrente.
GET /api/v1/usersBearer válidoLista paginada; page e per_page vêm da query string.
GET /api/v1/users/{id}Bearer válidoDevolve UserResource ou JSON 404.
OPTIONS para os caminhos declaradosCORSPreflight recebe 204 no CorsMiddleware antes da action.

Métodos e parâmetros de rota

O Router disponibiliza GET, POST, PUT, PATCH, DELETE, OPTIONS e match(). Placeholders como /users/{id} são passados por nome à action; parâmetros tipados escalares podem ser convertidos via Reflection. O override via _method só é aplicado quando a requisição original é POST e o valor é PUT/PATCH/DELETE; é uma conveniência para forms, não substituto necessário para clientes HTTP. Router::resource() gera sete actions convencionais, inclusive create/edit; ele não cria JSON Resources nem autenticação, então para API prefira registrar os verbos necessários explicitamente.

Todas as rotas ficam no grupo /api/v1 com CorsMiddleware e RateLimitMiddleware:api. As rotas privadas acrescentam ApiAuthMiddleware; login acrescenta o perfil login. A API não usa views para as respostas normais nem registra CsrfMiddleware no grupo; a segurança de escrita vem do Bearer e de autorização explícita. O bootstrap ainda inicia a sessão globalmente. É o mesmo Router, Request, Controllers, Models e banco do restante do projeto.

2. Arquivos: onde cada peça fica

Caminho no projetoResponsabilidade
public/index.phpFront controller; recebe a requisição e chama o bootstrap.
bootstrap/app.phpCarrega Composer/.env, configura caminhos e instancia Application.
routes/api.phpRegistra prefixo, verbos, Controllers e middlewares. A Application também carrega routes/api*.php.
config/api.php + .envCORS e configuração de integrações; CORS_ALLOWED_ORIGINS alimenta as origens aceitas.
app/Http/Controllers/Api/Actions HTTP de API; ApiController é a base da aplicação, sobre Controller do Framework.
app/Http/Requests/ e app/Http/Requests/Api/FormRequests compartilhados ou específicos da API. A classe base é App\Http\Requests\FormRequest.
app/Http/Middlewares/Bearer auth, CORS e rate limit pertencem à aplicação de exemplo.
app/Http/Resources/Transformação explícita Model → array JSON; por exemplo, UserResource.
app/Models/, app/Repositories/, app/Services/Modelo/persistência, consultas de domínio e regras de aplicação; use apenas as camadas necessárias.
framework/Http/Request.php, Router.php, framework/Http/Api/ApiResponse.phpInfraestrutura real de entrada, despacho e envelope JSON.
database/migrations/Schema da aplicação; usuários e api_tokens já têm migrations no projeto.

Pré-requisitos e configuração

Parta da raiz do projeto. Instale dependências, copie e edite .env, configure MySQL e crie o banco vazio com as credenciais corretas antes de migrar (migrate cria tabelas; não cria o database MySQL). As rotas públicas de demonstração sem banco podem responder sem consulta, mas login e endpoints de usuários dependem das tabelas e das migrations.

# Na raiz do projeto; preencha antes .env com dados locais de MySQL
composer install
cp .env.example .env
# Edite .env: APP_URL, APP_ENV, APP_DEBUG e DB_HOST/PORT/DATABASE/USERNAME/PASSWORD
php mvc key:generate
php mvc migrate
# Opcional: somente em banco local descartável/de desenvolvimento
php mvc seed:run UserSeeder
php mvc serve

php mvc serve inicia o servidor local na porta 8000. O document root de produção deve ser public/; o .htaccess usa mod_rewrite para encaminhar caminhos inexistentes ao front controller. O comando de servidor é para desenvolvimento, não para produção. A aplicação precisa de vendor/autoload.php; se não houver dependências instaladas, o bootstrap responde 500.

Para permitir uma SPA em outra origem, configure por exemplo CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com. O wildcard * só é aceito fora de APP_ENV=production; CORS só governa leitura pelo navegador e não autentica nem autoriza clientes. Em caminhos novos, adicione também uma rota OPTIONS explícita quando o navegador for fazer preflight (por exemplo, por usar Authorization ou Content-Type: application/json); o Router precisa casar o caminho OPTIONS para o CorsMiddleware poder encerrá-lo com 204.

3. Iniciante — criar seu primeiro endpoint do zero

Cenário e objetivo

Você quer confirmar que a aplicação inicia e que uma rota API devolve JSON, sem banco, Model ou autenticação. O endpoint será público e não exporá dados privados. Ele será acrescentado ao grupo existente em routes/api.php, que já aplica CORS e rate limit.

Etapa 1 — crie o Controller

Crie app/Http/Controllers/Api/HealthApiController.php. Herdar de ApiController dá acesso aos helpers de envelope e à Request herdada.

<?php
namespace App\Http\Controllers\Api;

final class HealthApiController extends ApiController
{
    public function show(): void
    {
        $this->success(
            ['api' => 'v1', 'status' => 'ok'],
            'API disponível.'
        );
    }
}

O método chama success(data, message). Esse helper envia status 200, Content-Type: application/json e o envelope padronizado; é terminal e encerra a requisição.

Etapa 2 — registre a rota

Em routes/api.php, importe a classe e acrescente a action dentro do closure já existente do grupo /api/v1; não crie um segundo prefixo acidentalmente.

<?php
use App\Http\Controllers\Api\HealthApiController;

// Dentro do group /api/v1 já existente em routes/api.php:
$r->get('/health', [HealthApiController::class, 'show']);

O Router combina o prefixo do grupo com /health, portanto o caminho final é /api/v1/health. Se a action não existir ou o middleware estiver escrito errado, isso vira falha de servidor; confira a grafia e os namespaces.

Etapa 3 — execute e confira

Em outro terminal, rode o servidor local. Depois da chamada, espere HTTP 200, Content-Type JSON e os campos de envelope a seguir:

curl -i http://localhost:8000/api/v1/health \
  -H 'Accept: application/json'
{
  "success": true,
  "message": "API disponível.",
  "data": {"api": "v1", "status": "ok"}
}

Se receber 404, confirme se salvou o arquivo na pasta certa, se adicionou a rota ao grupo real e se acessou o método/caminho corretos. Se receber 500 por autoload, rode composer install e confira o log PHP.

4. Entradas HTTP e validação

A classe Framework\Http\Request encapsula dados globais, mas os métodos não juntam todas as fontes:

OrigemComo lerDetalhe que evita erro
Parâmetro de caminho /users/{id}Argumento tipado da action: show(int $id)Router mapeia por nome e faz cast escalar; cast para int não valida que a entrada original era um inteiro positivo.
Query string ?page=2$this->request->get('page', 1)get() lê $_GET e sanitiza texto; valide/faça cast e limite valores.
Form-urlencoded / form POSTpost(), all(), raw() ou FormRequestall() lê somente $_POST, não a query nem JSON.
JSON no body$this->request->json() ou um FormRequest com Content-Type JSONinput(), post() e all() não leem o JSON. O JSON malformado vira array vazio em Request::json(); não espere exceção JSON automática.
Headersheader('Accept'), header('Authorization')Request monta o índice HTTP_* em $_SERVER; para detectar corpo JSON use isJson() (que lê CONTENT_TYPE), não dependa de header('Content-Type').
Arquivo enviadofile('foto')/hasFile('foto') e Validator/UploadFormRequest inclui o nome original no input, mas não valida o conteúdo binário do upload por si só.

Use cabeçalhos coerentes: Accept: application/json informa o formato esperado ao cliente; Content-Type: application/json descreve o corpo JSON enviado. Os helpers ApiResponse sempre respondem JSON, independentemente de Accept. JSON de entrada deve ser objeto/campos, pois FormRequest converte o corpo em array associativo.

Cenário e objetivo

Agora o endpoint recebe nome no corpo JSON. Queremos rejeitar campo ausente ou curto antes de montar a resposta. Crie app/Http/Requests/Api/GreetingRequest.php e app/Http/Controllers/Api/GreetingApiController.php; use o helper de validação da base API.

<?php
namespace App\Http\Requests\Api;

use App\Http\Requests\FormRequest;

final class GreetingRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // endpoint didático público; remova/ajuste em produção
    }

    public function rules(): array
    {
        return ['name' => 'required|min:2|max:80'];
    }

    public function messages(): array
    {
        return ['name.required' => 'Informe o nome.'];
    }
}
<?php
namespace App\Http\Controllers\Api;

use App\Http\Requests\Api\GreetingRequest;

final class GreetingApiController extends ApiController
{
    public function store(GreetingRequest $request): void
    {
        $data = $this->validated($request); // encerra em JSON 422 se inválido
        $this->success(
            ['greeting' => 'Olá, ' . $data['name'] . '!'],
            'Saudação criada.'
        );
    }
}

O Router reconhece argumento de action cuja classe estende App\Http\Requests\FormRequest, instancia-o e passa os parâmetros de rota ao construtor. A validação é calculada no construtor, mas não envia automaticamente 422 para API: chame $this->validated($request). Esse helper devolve apenas dados que passaram nas regras ou encerra com envelope JSON 422. Consulte regras de validação e Request/FormRequest.

Registre também o verbo e preflight dentro do grupo:

<?php
use App\Http\Controllers\Api\GreetingApiController;
use App\Http\Controllers\Api\PreflightController;

// Dentro do group /api/v1 já existente; registre OPTIONS para o preflight do browser.
$r->options('/greetings', [PreflightController::class, 'handle']);
$r->post('/greetings', [GreetingApiController::class, 'store']);

Teste entrada válida e inválida. Com nome válido, espera-se 200; com nome ausente ou curto, espera-se 422, success:false e mapa de erros:

curl -i -X POST http://localhost:8000/api/v1/greetings \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ana"}'
{
  "success": true,
  "message": "Saudação criada.",
  "data": {"greeting": "Olá, Ana!"}
}
{
  "success": false,
  "message": "Os dados enviados são inválidos.",
  "errors": {"name": ["Informe o nome."]}
}

Para um endpoint que precise ler query strings, use Request::get() e passe os valores pelo Validator ou aplique validação explícita; o FormRequest desta implementação captura JSON ou POST, não mescla automaticamente $_GET.

5. Intermediário — autenticação Bearer e leitura paginada

Cenário e objetivo

O projeto já traz login API, tokens, consulta do usuário autenticado e listagem/leitura de usuários. Esse fluxo demonstra Request → FormRequest → Controller → Repository → Resource → ApiResponse. Não há escrita de usuário ativa antes de adicionar a extensão avançada abaixo.

Faça login e receba o token

POST /api/v1/auth/login aceita JSON, reutiliza LoginRequest, autentica via User::authenticate() e emite token com ApiTokenRepository. Em desenvolvimento, o seeder fornece contas de demonstração; leia o arquivo database/seeds/UserSeeder.php e nunca rode contas/senhas de demonstração em produção.

curl -i -X POST http://localhost:8000/api/v1/auth/login \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"SENHA_LOCAL"}'

Resposta representativa (valores de ID/data variam); copie o campo data.token para o cliente com cuidado. O valor puro é exibido uma única vez:

{
  "success": true,
  "message": "Login realizado com sucesso.",
  "data": {
    "token": "<TOKEN-EXIBIDO-UMA-VEZ>",
    "token_type": "Bearer",
    "user": {
      "id": 1,
      "name": "Administrador",
      "email": "admin@example.com",
      "role": "admin",
      "active": true,
      "created_at": "2026-09-28 10:00:00"
    }
  }
}

O Model gera token aleatório de 64 caracteres hexadecimais e grava apenas SHA-256 na tabela api_tokens; a migration cria chave única para hash e relação com users. O middleware procura token válido, exige usuário ativo, preenche ApiAuthContext e atualiza last_used_at. Envie o token em Authorization: Bearer …, não em query string. Logout revoga o registro atual; não há endpoint de refresh/revogação de todos os dispositivos exposto nas rotas.

Expiração real: o Model aceita expires_at, mas o endpoint de login chama issue($userId, 'api-login') sem data de expiração. Portanto, os tokens emitidos por esse fluxo não expiram pelo campo expires_at; permanecem válidos até serem revogados ou o usuário ser desativado. Não assuma que existe rotação ou refresh automático.

Consuma a lista autenticada

UserApiController::index() lê page e per_page pela query string; limita page ao mínimo 1 e per_page entre 1 e 100 (padrão 15). Ele chama UserRepository::paginate(), transforma cada objeto com UserResource::collection() e passa os metadados a paginated().

TOKEN='<cole o token recebido no login>'
curl -i 'http://localhost:8000/api/v1/users?page=1&per_page=15' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Accept: application/json'
{
  "success": true,
  "message": "Dados encontrados.",
  "data": [
    {
      "id": 1,
      "name": "Administrador",
      "email": "admin@example.com",
      "role": "admin",
      "active": true,
      "created_at": "2026-09-28 10:00:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 15,
    "total": 1,
    "last_page": 1,
    "from": 1,
    "to": 1
  }
}

O formato acima ilustra uma página com um resultado. UserResource expõe explicitamente id, name, email, role, active e created_at; não inclui password nem remember_token. Com coleção vazia, o Model desta versão retorna total=0, last_page=0, from=1, to=0. O limite 100 é política deste Controller/gerador de API, não teto geral do Model.

Leia um registro e entenda o 404

GET /api/v1/users/{id} recebe int $id, consulta UserRepository::findById() e responde com Resource ou envelope JSON de erro 404. Já um caminho sem rota, por exemplo /api/v1/nao-existe, passa pelo handler genérico de 404 do Router, que atualmente imprime HTML. “API usa JSON” não significa que todo handler global negocia JSON.

{
  "success": false,
  "message": "Usuário não encontrado."
}

6. Avançado — extensão organizada de cadastro

Exemplo de extensão, não endpoint já disponível. O código atual só expõe GET para usuários. Abaixo adicionamos um POST usando tabela e Model existentes. Os novos Request/Service e o wrapper de Repository são arquivos/métodos de aplicação que você cria; não são APIs mágicas do Framework. A migration users, User::createWithHash(), UserRepository::findByEmail(), UserResource e os helpers do Controller já existem.

Cenário e objetivo

Um cliente autenticado precisa cadastrar um usuário comum. O body contém nome, e-mail e senha; não aceitamos role ou active vindos do cliente, para impedir autoelevação de privilégio. A senha precisa ser preservada durante sanitização e armazenada com hash pelo Model. O resultado usa 201 e Resource, nunca o objeto bruto do Model.

Passo A — crie e valide o FormRequest

O gerador php mvc make:api-request StoreUserApiRequest cria um stub sob app/Http/Requests/Api/; revise-o e complete autorização/regras. A action recebe o objeto tipado; o middleware da rota executa antes e preenche ApiAuthContext.

<?php
namespace App\Http\Requests\Api;

use App\Http\Requests\FormRequest;
use Framework\Http\Request;
use Framework\Http\Api\ApiAuthContext;

final class StoreUserApiRequest extends FormRequest
{
    public function authorize(): bool
    {
        // A rota aplica ApiAuthMiddleware antes de construir este FormRequest.
        return ApiAuthContext::check();
    }

    public function rules(): array
    {
        return [
            'name' => 'required|min:2|max:150',
            'email' => 'required|email|max:180',
            'password' => 'required|min:8',
        ];
    }

    public function messages(): array
    {
        return [
            'email.email' => 'Informe um e-mail válido.',
        ];
    }

    public function sanitize(): array
    {
        $clean = Request::sanitizeValue($this->input);
        $clean['email'] = strtolower(trim((string) ($this->input['email'] ?? '')));
        $clean['password'] = (string) ($this->input['password'] ?? '');
        return $clean;
    }
}

Passo B — exponha a escrita segura pelo Repository

User::createWithHash() já define hash bcrypt, role padrão member e active padrão 1. O Repository atual não tem esse wrapper, então acrescente explicitamente o método a app/Repositories/UserRepository.php:

<?php
namespace App\Repositories;

use Framework\Database\Repository;
use App\Models\User;

final class UserRepository extends Repository
{
    protected string $modelClass = User::class;

    public function findByEmail(string $email): object|false
    {
        return $this->model()->findBy('email', $email);
    }

    public function getActive(): array
    {
        return $this->model()->where('active', 1)->orderBy('name')->get();
    }

    public function search(string $term, int $page = 1, int $perPage = 15): array
    {
        $like = '%' . $term . '%';
        return $this->model()
            ->select('id', 'name', 'email', 'role', 'active', 'created_at')
            ->where('name', $like, 'LIKE')
            ->orWhere('email', $like, 'LIKE')
            ->orderBy('name')
            ->paginate($perPage, $page);
    }

    // Novo wrapper para o método createWithHash() já existente em User.
    public function createWithHash(array $data): string|false
    {
        /** @var User $model */
        $model = $this->model();
        return $model->createWithHash($data);
    }
}

A regra do Request verifica formato e limites; o Service verifica duplicidade antes de gravar. A constraint UNIQUE existente em users.email é a proteção concorrente definitiva; o Service converte somente violação MySQL de entrada duplicada (SQLSTATE 23000/código 1062) em resultado de domínio, sem colocar detalhes SQL na resposta.

Passo C — crie o Service

Crie app/Services/Users/RegisterUserService.php. Service recebe dados já validados e não conhece HTTP, status, headers nem JSON. A injeção é explícita: o projeto não declara um container de dependências automático.

<?php
namespace App\Services\Users;

use App\Repositories\UserRepository;
use Framework\Core\Service;
use PDOException;

final class RegisterUserService extends Service
{
    public function __construct(
        private readonly UserRepository $users = new UserRepository()
    ) {}

    /** @return array{created:bool,reason?:string,id?:int} */
    public function register(array $data): array
    {
        if ($this->users->findByEmail($data['email'])) {
            return ['created' => false, 'reason' => 'email_exists'];
        }

        try {
            // Método novo do UserRepository, que delega a User::createWithHash().
            $id = $this->users->createWithHash($data);
        } catch (PDOException $e) {
            // A constraint UNIQUE do banco cobre a corrida entre validação e INSERT.
            if ((string) $e->getCode() === '23000' && (int) ($e->errorInfo[1] ?? 0) === 1062) {
                return ['created' => false, 'reason' => 'email_exists'];
            }
            throw $e;
        }

        if ($id === false) {
            return ['created' => false, 'reason' => 'persistence'];
        }

        return ['created' => true, 'id' => (int) $id];
    }
}

Passo D — orquestre no Controller e devolva Resource

Adicione imports/propriedades e inicialização no Controller, preservando parent::__construct(); implemente a action. O snippet indica a action completa e as dependências que ela espera:

<?php
namespace App\Http\Controllers\Api;

use App\Http\Requests\Api\StoreUserApiRequest;
use App\Services\Users\RegisterUserService;
use App\Http\Resources\UserResource;
use App\Repositories\UserRepository;
use Framework\Support\Logger;

final class UserRegistrationApiController extends ApiController
{
    private UserRepository $users;
    private RegisterUserService $registration;

    public function __construct()
    {
        parent::__construct();
        $this->users = new UserRepository();
        $this->registration = new RegisterUserService($this->users);
    }

    public function store(StoreUserApiRequest $request): void
    {
        $data = $this->validated($request);
        $result = $this->registration->register($data);

        if (!$result['created'] && ($result['reason'] ?? '') === 'email_exists') {
            $this->error('Este e-mail já está cadastrado.', [], 409);
        }
        if (!$result['created']) {
            $this->error('Não foi possível criar o usuário.', [], 500);
        }

        $user = $this->users->findById($result['id']);
        if (!$user) {
            Logger::error('API: usuário criado, mas não pôde ser recarregado.', ['id' => $result['id']]);
            $this->error('Não foi possível concluir a operação.', [], 500);
        }

        $this->success(UserResource::make($user), 'Usuário criado.', 201);
    }
}

Com sucesso, a senha não aparece porque UserResource escolhe os campos de saída; o id recém-criado é relido pelo Repository para produzir o objeto usado pela Resource.

Passo E — registre POST e OPTIONS

Em routes/api.php, dentro do mesmo grupo, adicione POST protegido e OPTIONS para o preflight. O login já é público; este exemplo de cadastro exige um token existente. Como ApiAuthMiddleware apenas autentica, ele não aplica política de papel/admin; se o recurso exigir autorização mais restrita, escreva e registre essa política explicitamente.

<?php
use App\Http\Controllers\Api\UserRegistrationApiController;

// Dentro do group /api/v1 em routes/api.php.
// OPTIONS /users já está registrado na aplicação existente.
$r->post('/users', [UserRegistrationApiController::class, 'store'], ['ApiAuthMiddleware']);

Passo F — teste o contrato

Envie os três campos permitidos, com um token obtido no login:

curl -i -X POST http://localhost:8000/api/v1/users \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Joana","email":"joana@example.com","password":"UmaSenha-Forte-123"}'

O resultado esperado é HTTP 201 e um Resource sem senha:

{
  "success": true,
  "message": "Usuário criado.",
  "data": {
    "id": 42,
    "name": "Joana",
    "email": "joana@example.com",
    "role": "member",
    "active": true,
    "created_at": "2026-09-28 10:15:00"
  }
}

Teste também: token ausente (401), token inválido/expirado (401), e-mail repetido (409 pelo Service), campo ausente ou e-mail inválido (422) e ID de consulta inexistente (404). Para qualquer erro inesperado, o comportamento genérico atual pode ser HTML 500; veja tratamento de falhas. Não esconda a falha de banco com um 201.

Quando acrescentar Model/migration

O exemplo reaproveita a tabela existente, portanto não cria Model nem migration. Para uma entidade nova, o fluxo é: php mvc make:migration e editar a migration PHP; php mvc make:model e conferir tabela/fillable; php mvc make:repository e declarar $modelClass; opcionalmente Service/FormRequest/Resource/Controller; registrar verbos explícitos em routes/api.php; rodar php mvc migrate. Gerador de Model não cria migration executável automaticamente e gerador de Controller não ativa rotas.

7. Middleware, CORS e controle de acesso

  • ApiAuthMiddleware: exige o prefixo Bearer, valida o hash do token, usuário e estado ativo; erros de autenticação são JSON 401. Não confundir com AuthMiddleware Web, baseado em sessão.
  • ApiAuthContext: fornece check(), user(), id() e token() para a requisição atual. Em API, use esse contexto, não Auth::check() ou Controller::user(), que leem autenticação de sessão.
  • CorsMiddleware: lê config/api.php; permite origens exatas configuradas e responde OPTIONS com 204. Origem não autorizada não recebe Access-Control-Allow-Origin. CORS não impede chamadas server-to-server e não substitui token/autorização.
  • RateLimitMiddleware: perfil api permite 60 chamadas/minuto por IP; perfil login permite 5/15 min e é aplicado adicionalmente ao limite do grupo. O armazenamento é em arquivos em storage/cache/ratelimit; não é Redis nem limitador distribuído.

Detalhe importante: o código do rate limiter escolhe JSON pelo Content-Type: application/json (Request::isJson()) ou por X-Requested-With, não apenas por Accept. Assim, uma requisição GET que ultrapasse o limite e envie somente Accept: application/json pode receber o fluxo HTML de redirect atual. Verifique esse cenário no deployment se o contrato exigir que todo 429 de API seja JSON. O perfil de login lê e-mail de $_POST ao montar a chave; em login JSON esse campo não está em $_POST e o bucket acaba sendo por IP com e-mail vazio nesta implementação.

8. Respostas, status HTTP e exceções

App\Http\Controllers\Api\ApiController expõe success(data,message,status,meta), error(message,errors,status), paginated(result,message) e validated(FormRequest). Eles delegam a Framework\Http\Api\ApiResponse, que seta Content-Type/status, serializa JSON e chama exit. Sucesso tem success, message, data e meta opcional; erro tem success:false, message e errors apenas quando não vazio.

StatusQuando aparece neste códigoFormato/observação
200Sucesso padrão de ApiResponse; login, logout, me e leitura usam o default.JSON envelope.
201Suportado por argumento de status de success(); usado na extensão avançada, não nas rotas ativas originais.JSON envelope de sucesso.
204Preflight OPTIONS encerrado pelo CorsMiddleware.Sem corpo.
400Status padrão de error() quando não se informa outro.JSON quando chamada pelo helper API.
401Bearer ausente/inválido, credencial inválida ou expirada.JSON da API. Usuário inativo também tende a 401 porque User::authenticate() o rejeita antes da branch 403 do controller de login.
403FormRequest com authorize() === false.JSON se Accept ou Content-Type forem JSON; este caminho envia payload simples sem ApiResponse.
404UserApiController::show() para ID sem registro.JSON. Rota/path sem correspondência usa handler genérico Router e devolve HTML 404.
405URI existe, mas verbo não está registrado.O Router envia Allow e JSON em caminho /api/; endpoint POST /users ainda não existe antes do exemplo avançado.
409Conflito da extensão de cadastro (e-mail duplicado).JSON quando o Controller chama error com status 409.
422FormRequest inválido via ApiController::validated().JSON com mapa de mensagens por campo.
429Limite de requisições atingido.Retry-After; JSON/redirect depende dos checks de AJAX/Content-Type citados acima.
500Falha não tratada durante middleware/action ou serialização.ApiResponse falha ao codificar → JSON 500. Throwable capturado pelo Router usa view/fallback HTML 500; não existe handler global que normalize toda exceção em JSON.
419 / 503Constam no mapa de mensagens de erro da Application.Não são um contrato geral de endpoints API ativos; 419 é ligado a CSRF Web e a API não registra CsrfMiddleware.

Para manter JSON também em falhas conhecidas, trate erros esperados na action/Service e chame $this->error() com status adequado; registre contexto técnico com Logger, mas não retorne SQL, stack trace, arquivo, token ou senha ao consumidor. Exceções desconhecidas não são automaticamente convertidas em envelope API: Router captura falha da rota e usa view/fallback de erro 500. O abort() do Controller base não é substituto de ApiController::error() para este contrato. Não há uma classe genérica Framework\Http\Response.

9. Como testar, consumir e manter

Teste do endpoint e token

Use cURL com -i para conferir status e headers. Primeiro faça login, depois substitua $TOKEN pelo valor de data.token:

curl -i -X POST http://localhost:8000/api/v1/auth/login \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"SENHA_LOCAL"}'
TOKEN='<cole o token recebido no login>'
curl -i 'http://localhost:8000/api/v1/users?page=1&per_page=15' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Accept: application/json'

Para verificar OPTIONS no browser, configure origem no .env e envie o preflight:

curl -i -X OPTIONS http://localhost:8000/api/v1/users \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type'

Confirme 204, Access-Control-Allow-Origin somente para origem permitida e headers/métodos declarados. cURL não aplica política CORS; a restrição é executada por browsers.

Matriz mínima antes de publicar

CasoEsperado no código/contrato
Rota pública de health / JSON válido200, Content-Type JSON e envelope estável.
Login com senha inválida401; não revelar se e-mail ou senha isoladamente estava errada.
Login com campo ausente422 e errors por campo.
Sem Authorization em endpoint privado401.
Token revogado no logoutPróxima chamada protegida com esse token: 401.
ID válido no formato, mas inexistente404 JSON no GET /users/{id}.
Verbo não registrado405; verificar header Allow e corpo.
Origem CORS aceita/rejeitada + OPTIONS204 no preflight e cabeçalho ACAO só para origem aceita.
Limite de chamadas429 e Retry-After; conferir o formato JSON/HTML conforme os cabeçalhos usados.
Erro inesperado de Controller500; confirmar que não vaza detalhes e notar que fallback pode ser HTML.

O projeto declara PHPUnit e configura tests/Unit/tests/Feature com o script composer test, mas não entrega uma suíte de testes HTTP API ou cliente de testes pronto nessas pastas. Adicione seus próprios testes com banco de teste isolado; não execute testes que apagam/migram um banco de produção. Os exemplos desta página podem ser testados manualmente via cURL no ambiente local.

Consumo de outra API a partir do MVC

Isso é fluxo de saída, não uma rota recebida. Encapsule-o em Integration/Gateway/Service e use Framework\Http\Api\ApiClient (cURL, timeout por instância, Bearer/basic auth, ApiException); não escreva cURL direto no Controller. A referência de construtor, retorno, timeout e erros está em ApiClient e ApiRequest. Não misture o token do cliente que chama sua API com credenciais usadas pelo servidor para chamar provedores externos.

10. O que evitar — e como fazer corretamente

EviteProblemaFaça assim
Tratar comentários de rota/gerador como endpoints ativosComentários não registram rota; stub pode conter método 501.Confira routes/api.php e implemente Controller/middleware antes de anunciar o endpoint.
Ler JSON por input()/all()Esses helpers não mesclam body JSON.Use FormRequest com Content-Type JSON ou json(); depois valide.
Usar sanitização como validação de domínioEscape HTML não garante formato, faixa ou autorização e pode alterar senha.Use Validator/FormRequest; personalize sanitize() para preservar senha como LoginRequest faz.
Serializar Model diretamente ou confiar só em $hiddenUma nova coluna pode vazar; hidden não é filtro universal de json_encode.Exponha lista explícita em ApiResource.
Usar Auth Web para validar BearerAuth/AuthMiddleware trabalha com sessão/cookie e não verifica token API.Registre ApiAuthMiddleware e use ApiAuthContext.
Confundir CORS com controle de acessoCurl, apps nativos e servidores não são bloqueados por CORS.Use autenticação/autorização real em cada rota protegida.
Usar * CORS em produção ou pôr token na URLA origem amplia superfície; query pode vazar em logs/histórico.Allowlist por origem e header Authorization; proteja armazenamento do cliente.
Assumir expiração/refresh/revogação global de tokenLogin atual emite sem expires_at; não há endpoints de refresh/logout-all.Revogue pelo fluxo disponível; implemente e teste expiração/rotação antes de depender dela.
Assumir que ApiAuthMiddleware checa papelEle valida token e usuário ativo; não aplica escopos ou papel.Defina regra de autorização específica antes de permitir operações sensíveis.
Usar Router::resource() como REST JSON prontoGera também create/edit e não cria Resource, validação, auth ou implementação; pode registrar actions HTML inexistentes.Registre explicitamente os verbos API e aplique middleware/política.
Retornar 200 para toda operaçãoClientes não distinguem criação, invalidação e falha.Use 200/201/204/4xx adequados ao contrato; helpers API aceitam status explícito.
Esperar que toda exceção vire JSONFallback Router/Application é HTML e não mapeia todo erro de domínio em envelope.Trate falhas esperadas no Controller/Service, logue internamente e responda com ApiController helpers.
Confiar apenas em regra uniqueRequisições concorrentes podem passar na validação ao mesmo tempo.Mantenha UNIQUE no banco e traduza conflito conhecido sem expor SQL.
Usar AuthService Web para emitir token APIEsse Service usa Auth de sessão; o fluxo Bearer existente é implementado em AuthApiController/ApiTokenRepository.Separe contexto de sessão e token; reaproveite regras puras, não sessão como credencial de API.

11. Recursos que não existem ou não são automáticos

  • Não há OpenAPI/Swagger gerado nem catálogo que registre automaticamente rotas novas; esta página e a referência de classes são documentação manual.
  • Não há injeção automática de Controllers/Services/Repositories. Router instancia Controller sem argumentos; construtores inicializam dependências explicitamente. Só FormRequest tipado na assinatura de action tem construção especial.
  • Não há middleware pipeline com request/response encadeados; middleware é classe cujo handle(Request $request, ?param) roda antes da action e pode encerrar a saída.
  • Não há CRUD de escrita de usuários ativo; no generator API, store/update podem estar marcados 501 até serem implementados. POST/PUT/PATCH/DELETE só existem depois de registrados.
  • Não há JWT, OAuth2, refresh token, scopes/permissões por token ou policy engine no middleware API entregue; há token aleatório pessoal com hash SHA-256 e contexto de usuário.
  • Não há resposta JSON global para 404 de rota ou Throwable de ação; use helpers explícitos para contratos de API, e reveja os handlers HTML 404/500 se seu produto exigir JSON uniforme.
  • config/api.php trata CORS e integrações de provedor; isso não configura automaticamente autenticação por token nem validação dos endpoints.

Checklist de publicação

  1. Confirme que cada rota está registrada, protegida e testada com verbo/caminho definitivos.
  2. Use HTTPS, APP_DEBUG=false em produção e nunca publique .env, senhas, tokens ou stack traces.
  3. Configure origens CORS exatas e teste preflight; CORS não substitui autorização.
  4. Troque/remova as contas do UserSeeder; restrinja respostas via Resource e não aceite role/active sem política.
  5. Decida política de expiração/revogação de tokens: o login ativo não configura expires_at.
  6. Valide entrada, limite paginação, use constraints no banco e teste 401/403/404/405/409/422/429/500.
  7. Leia as páginas de roteamento, Request/FormRequest, respostas, middleware, Repository, Service e Model ao ampliar o domínio.

Rate limiting: comportamento durante falha de armazenamento

Os perfis api/default são fail-open: se o armazenamento em arquivo falhar e hit() retornar null, a requisição segue sem throttling. Perfis de login/register/forgot são fail-closed e podem bloquear quando não conseguem verificar o limite. Monitore permissões, espaço em disco, storage/cache/ratelimit e erros de I/O; o limite não é garantia durante falhas de armazenamento.

409 por conflito UNIQUE não é garantido em corrida

O exemplo de cadastro pode mapear para 409 uma duplicidade que foi detectada previamente pela consulta de domínio. Porém, se outra requisição inserir o mesmo valor entre a consulta e o INSERT, a violação UNIQUE no banco não é traduzida automaticamente para 409 pelo Service atual: ele não preserva necessariamente SQLSTATE/errorInfo de modo que a API possa distinguir a condição. Não capture indiscriminadamente PDOException no controller supondo que o framework a propaga; documente 409 concorrente apenas após uma alteração explícita que preserve e mapeie o erro do banco.