Construir uma API com Laravel puro é possível — mas o framework oferece ferramentas específicas que tornam o processo mais organizado e profissional: API Resources para formatar respostas, Sanctum para autenticação stateless, rate limiting nativo, e convenções que tornam sua API previsível para quem a consome.
Configurando rotas de API
<?php
// routes/api.php — rotas aqui são prefixadas com /api automaticamente.
//
// ANTES DE TUDO, no Laravel 11+: este arquivo NÃO vem no projeto novo. O
// skeleton traz só `routes/web.php` e `routes/console.php`, e o
// `bootstrap/app.php` não registra rota de API nenhuma. Quem cria o
// arquivo, liga o `api:` no `withRouting()` e já instala o Sanctum é:
//
// php artisan install:api
//
// Sem esse comando, toda rota escrita aqui responde 404 — e sem erro,
// porque o arquivo simplesmente não é lido.
use App\Http\Controllers\Api\ProdutoController;
use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;
// Rotas públicas
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/register', [AuthController::class, 'register']);
Route::get('/produtos', [ProdutoController::class, 'index']);
Route::get('/produtos/{produto}', [ProdutoController::class, 'show']);
// Rotas protegidas com Sanctum
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::apiResource('pedidos', PedidoController::class);
Route::post('/produtos', [ProdutoController::class, 'store']);
Route::put('/produtos/{produto}', [ProdutoController::class, 'update']);
Route::delete('/produtos/{produto}', [ProdutoController::class, 'destroy']);
});
API Resources — formatando respostas
Resources transformam models em arrays JSON, desacoplando a estrutura da resposta da estrutura do banco:
php artisan make:resource ProdutoResource
php artisan make:resource ProdutoCollection
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProdutoResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nome' => $this->nome,
'slug' => $this->slug,
'preco' => (float) $this->preco,
'preco_formatado' => $this->preco_formatado,
'estoque' => $this->estoque,
'disponivel' => $this->estoque > 0 && $this->ativo,
// Relacionamento — só inclui se foi carregado (evita N+1)
'categoria' => new CategoriaResource($this->whenLoaded('categoria')),
// Campo condicional — só para admins
'custo' => $this->when(
$request->user()?->isAdmin(),
$this->custo
),
'criado_em' => $this->created_at->toIso8601String(),
'atualizado_em' => $this->updated_at->toIso8601String(),
];
}
}
// Collection com metadados extras
class ProdutoCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'por_pagina'=> $this->perPage(),
'pagina' => $this->currentPage(),
],
];
}
}
Controller de API
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProdutoResource;
use App\Models\Produto;
use App\Http\Requests\ProdutoRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProdutoController extends Controller
{
public function index(): AnonymousResourceCollection
{
$produtos = Produto::with('categoria')
->ativo()
->paginate(15);
return ProdutoResource::collection($produtos);
}
public function show(Produto $produto): ProdutoResource
{
$produto->load('categoria', 'avaliacoes');
return new ProdutoResource($produto);
}
public function store(ProdutoRequest $request): JsonResponse
{
$produto = Produto::create($request->validated());
return (new ProdutoResource($produto))
->response()
->setStatusCode(201);
}
// `$this->authorize()` vem do trait `AuthorizesRequests`. Até o Laravel
// 10 ele estava no `Controller` base do skeleton; do 11 em diante esse
// arquivo é uma classe abstrata vazia, e sem declarar o trait a
// chamada morre com `Call to undefined method`. Declare no topo da
// classe: `use AuthorizesRequests;`
public function update(ProdutoRequest $request, Produto $produto): ProdutoResource
{
$this->authorize('update', $produto);
$produto->update($request->validated());
return new ProdutoResource($produto->fresh('categoria'));
}
public function destroy(Produto $produto): JsonResponse
{
$this->authorize('delete', $produto);
$produto->delete();
return response()->json(null, 204);
}
}
Autenticação com Sanctum
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\PersonalAccessToken;
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'senha' => ['required'],
'device' => ['required', 'string'],
]);
if (!Auth::attempt([
'email' => $request->email,
'password' => $request->senha,
])) {
throw ValidationException::withMessages([
'email' => 'Credenciais inválidas.',
]);
}
$user = $request->user();
$token = $user->createToken(
name: $request->device,
abilities: ['*'],
expiresAt: now()->addDays(30),
)->plainTextToken;
return response()->json([
'token' => $token,
'usuario' => [
'id' => $user->id,
'nome' => $user->name,
'email' => $user->email,
],
]);
}
public function logout(Request $request): JsonResponse
{
// Revoga apenas o token atual.
//
// O `instanceof` não é excesso de zelo: quando a requisição vem
// autenticada pelo COOKIE (modo SPA do Sanctum), o
// `currentAccessToken()` devolve um `TransientToken`, que não é um
// registro no banco e não tem `delete()`. Chamar direto dá
// `Call to undefined method Laravel\Sanctum\TransientToken::delete()`.
$token = $request->user()->currentAccessToken();
if ($token instanceof PersonalAccessToken) {
$token->delete();
}
return response()->json(['mensagem' => 'Logout realizado.']);
}
}
Tratamento de erros na API
<?php
// bootstrap/app.php — no Laravel 11+ o `app/Exceptions/Handler.php`
// deixou de existir, e o tratamento de erros passou para cá.
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->renderable(function (\Throwable $e, Request $request) {
if (!$request->expectsJson()) return null;
return match(true) {
$e instanceof ValidationException =>
response()->json([
'mensagem' => 'Dados inválidos.',
'erros' => $e->errors(),
], 422),
$e instanceof ModelNotFoundException,
$e instanceof NotFoundHttpException =>
response()->json(['mensagem' => 'Recurso não encontrado.'], 404),
$e instanceof AuthenticationException =>
response()->json(['mensagem' => 'Não autenticado.'], 401),
$e instanceof \Illuminate\Auth\Access\AuthorizationException =>
response()->json(['mensagem' => 'Não autorizado.'], 403),
default =>
response()->json([
'mensagem' => 'Erro interno do servidor.',
'detalhe' => config('app.debug') ? $e->getMessage() : null,
], 500),
};
});
})
Rate Limiting
<?php
// app/Providers/AppServiceProvider.php
//
// (O `composer require laravel/sanctum` da seção anterior só é preciso
// em projeto que ainda não rodou o `install:api` — o comando já instala
// e publica o Sanctum.)
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?? $request->ip()
);
});
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perMinute(3)->by($request->input('email')),
];
});
}
// Em routes/api.php:
Route::middleware(['throttle:login'])->group(function () {
Route::post('/auth/login', [AuthController::class, 'login']);
});
Uma API é um contrato, e é aí que ela difere de uma aplicação web comum. A página renderizada pode mudar de layout todo mês sem que ninguém reclame; o campo preco que vira preco_em_centavos quebra o app de alguém que talvez nem saiba que você existe. Essa é a razão de ser dos Resources, e a razão de valer a pena mantê-los mesmo quando parecem cerimônia: com um Resource no meio, a coluna do banco pode ser renomeada à vontade que a resposta continua a mesma — e, quando o contrato precisar mesmo mudar, a mudança fica num arquivo só, visível em revisão de código, em vez de escapar por um toArray() de model.
A outra metade do contrato é o que acontece quando dá errado. Uma API que responde 200 com {"erro": "..."}, ou que devolve uma página HTML de stack trace para um cliente que pediu JSON, obriga quem consome a adivinhar — e quem adivinha erra em produção. O bloco de tratamento de erros vale ser escrito no primeiro dia do projeto, não no dia do primeiro chamado: código de status coerente, corpo com formato único, e detalhe só quando app.debug estiver ligado. Um cliente aprende rápido a lidar com qualquer formato de erro, desde que seja sempre o mesmo formato.
Fontes e leituras recomendadas
- Laravel API Resources: https://laravel.com/docs/eloquent-resources
- Laravel Sanctum: https://laravel.com/docs/sanctum
- Laravel Rate Limiting: https://laravel.com/docs/routing#rate-limiting
- RESTful API Design — Melhores práticas: https://restfulapi.net/
- Laravel — validacao, incluindo Form Requests e a resposta 422 automatica na API: https://laravel.com/docs/validation
- JSON:API — especificacao de formato de resposta, util mesmo quando nao se adota inteira: https://jsonapi.org/
Exercícios
Exercício 1
Construa uma API RESTful completa para o recurso Produto: CRUD com validação via FormRequest, API Resource com campos condicionais por role, paginação com metadados.
Ver resposta
✓ Resposta: when() e whenLoaded() fazem coisas diferentes e resolvem os dois problemas típicos de API. O primeiro remove a chave do JSON quando a condição é falsa — campo ausente, não null, que é o que evita vazar custo e margem por descuido. O segundo só inclui a relação se ela foi carregada, transformando o N+1 em campo ausente em vez de 20 consultas silenciosas. E o teto no per_page não é preciosismo: sem ele, ?per_page=100000 é um DoS de uma linha de query string.
<?php
// ============ no terminal ============
// php artisan make:controller Api/V1/ProdutoController --api --model=Produto
// php artisan make:resource ProdutoResource
// php artisan make:request StoreProdutoRequest
// ---------------------------------------- routes/api.php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\ProdutoController;
use Illuminate\Support\Facades\Route;
// apiResource não cria create/edit (que devolvem formulário HTML) — só as
// cinco rotas que fazem sentido numa API.
Route::apiResource('produtos', ProdutoController::class)
->only(['index', 'show']);
Route::middleware('auth:sanctum')->group(function (): void {
Route::apiResource('produtos', ProdutoController::class)
->except(['index', 'show']);
});
// ---------------------------------------- app/Http/Requests/StoreProdutoRequest.php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProdutoRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('produtos.criar') ?? false;
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
return [
'nome' => ['required', 'string', 'min:3', 'max:255'],
'categoria_id' => ['required', 'integer', Rule::exists('categorias', 'id')],
'descricao' => ['nullable', 'string', 'max:5000'],
'preco' => ['required', 'numeric', 'min:0', 'max:999999.99'],
'estoque' => ['required', 'integer', 'min:0'],
'ativo' => ['boolean'],
// Validação de array: a primeira linha valida o array, a
// segunda valida CADA item dele.
'tags' => ['array', 'max:10'],
'tags.*' => ['string', 'max:30'],
];
}
/**
* Numa API, o redirect padrão do FormRequest não serve: o cliente quer
* JSON. O Laravel já devolve 422 com JSON quando o request tem
* Accept: application/json — este método existe para padronizar a
* ESTRUTURA da resposta.
*/
protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator): void
{
throw new \Illuminate\Http\Exceptions\HttpResponseException(
response()->json([
'mensagem' => 'Os dados enviados são inválidos.',
'erros' => $validator->errors(),
], 422)
);
}
}
// ---------------------------------------- app/Http/Resources/ProdutoResource.php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Produto */
class ProdutoResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nome' => $this->nome,
'slug' => $this->slug,
'descricao' => $this->descricao,
// Dinheiro como número, não string formatada: quem formata é o
// cliente, que conhece o idioma de quem está lendo.
'preco' => (float) $this->preco,
'disponivel' => $this->estoque > 0,
// whenLoaded evita o N+1 pela raiz: se o controller não fez
// with('categoria'), o campo simplesmente não aparece — em vez
// de disparar uma consulta por item da listagem.
'categoria' => new CategoriaResource($this->whenLoaded('categoria')),
// Campos por PAPEL. O when() com false remove a chave inteira
// do JSON, em vez de mandar null.
'estoque' => $this->when(
$request->user()?->can('produtos.editar') ?? false,
$this->estoque,
),
'custo' => $this->when(
$request->user()?->hasRole('admin') ?? false,
fn (): float => (float) $this->custo,
),
'margem' => $this->when(
$request->user()?->hasRole('admin') ?? false,
fn (): float => round((float) $this->preco - (float) $this->custo, 2),
),
'ativo' => $this->when(
$request->user()?->can('produtos.editar') ?? false,
(bool) $this->ativo,
),
// Data sempre em ISO-8601 com fuso: 'd/m/Y' numa API obriga o
// cliente a adivinhar o formato e o timezone.
'criado_em' => $this->created_at?->toIso8601String(),
'links' => [
'self' => route('produtos.show', $this->slug),
],
];
}
/** Metadados que acompanham TODA resposta deste recurso. */
public function with(Request $request): array
{
return ['api' => ['versao' => 'v1']];
}
}
// ---------------------------------------- app/Http/Controllers/Api/V1/ProdutoController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProdutoRequest;
use App\Http\Resources\ProdutoResource;
use App\Models\Produto;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class ProdutoController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$produtos = Produto::query()
->with('categoria')
->when($request->string('q')->trim()->value(), function ($consulta, string $termo): void {
$consulta->where('nome', 'like', "%{$termo}%");
})
->when($request->integer('categoria_id'), function ($consulta, int $id): void {
$consulta->where('categoria_id', $id);
})
->when($request->boolean('apenas_disponiveis'), function ($consulta): void {
$consulta->where('estoque', '>', 0);
})
// Teto no per_page: sem ele, ?per_page=100000 é um DoS de uma
// linha de query string.
->paginate(perPage: min($request->integer('per_page', 20), 100))
->withQueryString();
// A collection do Resource já embute meta (current_page, total…)
// e links (first, last, prev, next) na resposta.
return ProdutoResource::collection($produtos);
}
public function store(StoreProdutoRequest $request): JsonResponse
{
$produto = Produto::create($request->validated());
return (new ProdutoResource($produto->load('categoria')))
->response()
// 201, com o Location do recurso criado.
->setStatusCode(Response::HTTP_CREATED)
->header('Location', route('produtos.show', $produto->slug));
}
public function show(Produto $produto): ProdutoResource
{
return new ProdutoResource($produto->load('categoria'));
}
public function update(StoreProdutoRequest $request, Produto $produto): ProdutoResource
{
$produto->update($request->validated());
return new ProdutoResource($produto->load('categoria'));
}
public function destroy(Produto $produto): Response
{
$this->authorize('delete', $produto);
$produto->delete();
// 204: sucesso, sem corpo. Devolver {"sucesso": true} com 200 é
// desperdiçar o vocabulário do protocolo.
return response()->noContent();
}
}
// ---------------------------------------- tests/Feature/Api/ProdutoApiTest.php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Produto;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ProdutoApiTest extends TestCase
{
use RefreshDatabase;
public function test_listagem_tem_estrutura_e_metadados(): void
{
Produto::factory()->count(3)->create();
$this->getJson('/api/produtos')
->assertOk()
->assertJsonStructure([
'data' => [['id', 'nome', 'slug', 'preco', 'disponivel', 'links']],
'meta' => ['current_page', 'total', 'per_page'],
'links' => ['first', 'last', 'prev', 'next'],
])
->assertJsonCount(3, 'data');
}
public function test_campos_sensiveis_nao_vazam_para_visitante(): void
{
Produto::factory()->create(['custo' => 10.0]);
$this->getJson('/api/produtos')
->assertOk()
->assertJsonMissingPath('data.0.custo')
->assertJsonMissingPath('data.0.estoque');
}
public function test_admin_ve_custo_e_margem(): void
{
Produto::factory()->create(['preco' => 100.0, 'custo' => 60.0]);
$admin = Usuario::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin, 'sanctum')
->getJson('/api/produtos')
->assertOk()
->assertJsonPath('data.0.custo', 60.0)
->assertJsonPath('data.0.margem', 40.0);
}
public function test_listagem_nao_tem_n_mais_1(): void
{
Produto::factory()->count(20)->create();
DB::enableQueryLog();
$this->getJson('/api/produtos')->assertOk();
// Com whenLoaded + with('categoria'): consulta dos produtos,
// consulta do total e consulta das categorias. Sem o eager loading,
// seriam 20 a mais — e este teste é o que trava a regressão.
$this->assertLessThanOrEqual(4, count(DB::getQueryLog()));
}
public function test_per_page_tem_teto(): void
{
Produto::factory()->count(30)->create();
$this->getJson('/api/produtos?per_page=100000')
->assertOk()
->assertJsonPath('meta.per_page', 100);
}
public function test_criar_devolve_201_e_location(): void
{
$admin = Usuario::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin, 'sanctum')
->postJson('/api/produtos', [
'nome' => 'Teclado Mecânico',
'categoria_id' => \App\Models\Categoria::factory()->create()->id,
'preco' => 349.9,
'estoque' => 10,
])
->assertCreated()
->assertHeader('Location')
->assertJsonPath('data.nome', 'Teclado Mecânico');
}
public function test_dados_invalidos_devolvem_422_estruturado(): void
{
$admin = Usuario::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin, 'sanctum')
->postJson('/api/produtos', ['nome' => 'ab'])
->assertStatus(422)
->assertJsonStructure(['mensagem', 'erros' => ['nome', 'preco']]);
}
}
Exercício 2
Implemente autenticação com Sanctum: endpoints de login, logout e /api/me. Tokens com expiração de 24h. Proteja todas as rotas de escrita.
Ver resposta
✓ Resposta: Três coisas que só aparecem em produção. O plainTextToken existe uma vez — o banco guarda só o hash, então não há "recuperar token", há gerar outro. O token expirado não some da tabela: sem o sanctum:prune-expired agendado, personal_access_tokens vira a maior tabela do banco. E as abilities são a diferença entre "este usuário pode" e "este dispositivo pode": o app de leitura recebe 403 ao tentar escrever, mesmo pertencendo a um administrador.
<?php
// ============ no terminal ============
// composer require laravel/sanctum
// php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
// php artisan migrate
// ---------------------------------------- config/sanctum.php (trecho)
declare(strict_types=1);
return [
// Em MINUTOS. 60 * 24 = 24 horas.
//
// Detalhe crítico: o token expirado não é APAGADO da tabela, só deixa
// de valer. Sem uma limpeza periódica, personal_access_tokens cresce
// para sempre — daí o comando agendado mais abaixo.
'expiration' => 60 * 24,
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),
];
// ---------------------------------------- app/Models/Usuario.php (trecho)
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;
class Usuario extends Authenticatable
{
use HasApiTokens; // createToken(), tokens(), currentAccessToken()
}
// ---------------------------------------- app/Http/Controllers/Api/AuthController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\UsuarioResource;
use App\Models\Usuario;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$dados = $request->validate([
'email' => ['required', 'email'],
'senha' => ['required', 'string'],
'dispositivo' => ['nullable', 'string', 'max:100'],
]);
$chave = 'login-api:' . $request->ip();
if (RateLimiter::tooManyAttempts($chave, maxAttempts: 5)) {
return response()->json([
'mensagem' => 'Muitas tentativas.',
'tente_em' => RateLimiter::availableIn($chave),
], 429);
}
$usuario = Usuario::where('email', $dados['email'])->first();
// Hash::check mesmo com usuário inexistente seria melhor ainda
// (tempo constante); aqui o RateLimiter já limita a sondagem.
if ($usuario === null || !Hash::check($dados['senha'], $usuario->senha)) {
RateLimiter::hit($chave, decaySeconds: 60);
throw ValidationException::withMessages([
'email' => 'Credenciais inválidas.',
]);
}
RateLimiter::clear($chave);
$nomeDoToken = $dados['dispositivo'] ?? 'api';
// Abilities: o token carrega só o que aquele dispositivo precisa.
// App de leitura recebe ['produtos:ler'] e não consegue escrever
// nem que a conta seja de admin.
$habilidades = $usuario->hasRole('admin')
? ['*']
: ['produtos:ler', 'pedidos:ler', 'pedidos:criar'];
// expiresAt sobrescreve o config por token — útil para dar validade
// curta a um token de uso pontual.
$token = $usuario->createToken(
$nomeDoToken,
$habilidades,
now()->addDay(),
);
return response()->json([
// plainTextToken aparece UMA vez: o banco guarda só o hash.
// Perdeu, gera outro — não há como recuperar.
'token' => $token->plainTextToken,
'tipo' => 'Bearer',
'expira_em' => $token->accessToken->expires_at?->toIso8601String(),
'usuario' => new UsuarioResource($usuario),
], 201);
}
public function eu(Request $request): JsonResponse
{
return response()->json([
'data' => new UsuarioResource($request->user()->load('roles')),
'token' => [
'nome' => $request->user()->currentAccessToken()->name,
'habilidades' => $request->user()->currentAccessToken()->abilities,
'expira_em' => $request->user()->currentAccessToken()->expires_at?->toIso8601String(),
'ultimo_uso' => $request->user()->currentAccessToken()->last_used_at?->toIso8601String(),
],
]);
}
public function logout(Request $request): JsonResponse
{
// Só o token ATUAL: sair no celular não pode derrubar a sessão do
// desktop.
$request->user()->currentAccessToken()->delete();
return response()->json(['mensagem' => 'Token revogado.']);
}
public function logoutDeTodos(Request $request): JsonResponse
{
// A opção nuclear — o que se oferece depois de "minha conta foi
// acessada por outra pessoa".
$quantidade = $request->user()->tokens()->delete();
return response()->json([
'mensagem' => "{$quantidade} sessão(ões) encerrada(s).",
]);
}
public function renovar(Request $request): JsonResponse
{
$atual = $request->user()->currentAccessToken();
$novo = $request->user()->createToken(
$atual->name,
$atual->abilities,
now()->addDay(),
);
// Revoga o antigo só DEPOIS de criar o novo: na ordem inversa, uma
// falha no meio deixaria o cliente sem token nenhum.
$atual->delete();
return response()->json([
'token' => $novo->plainTextToken,
'expira_em' => $novo->accessToken->expires_at?->toIso8601String(),
]);
}
}
// ---------------------------------------- routes/api.php
declare(strict_types=1);
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\V1\ProdutoController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:10,1');
Route::get('/produtos', [ProdutoController::class, 'index']);
Route::get('/produtos/{produto}', [ProdutoController::class, 'show']);
Route::middleware('auth:sanctum')->group(function (): void {
Route::get('/me', [AuthController::class, 'eu']);
Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/logout-todos', [AuthController::class, 'logoutDeTodos']);
Route::post('/renovar', [AuthController::class, 'renovar']);
// A habilidade é checada no middleware: token de leitura recebe 403 ao
// tentar escrever, mesmo pertencendo a um usuário que poderia.
Route::middleware('abilities:produtos:escrever')->group(function (): void {
Route::post('/produtos', [ProdutoController::class, 'store']);
Route::put('/produtos/{produto}', [ProdutoController::class, 'update']);
Route::delete('/produtos/{produto}', [ProdutoController::class, 'destroy']);
});
});
// ---------------------------------------- routes/console.php
declare(strict_types=1);
use Illuminate\Support\Facades\Schedule;
// Token expirado continua na tabela até alguém apagar. Em API movimentada,
// personal_access_tokens vira a maior tabela do banco sem esta linha.
Schedule::command('sanctum:prune-expired --hours=24')->daily();
// ---------------------------------------- tests/Feature/Api/SanctumTest.php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class SanctumTest extends TestCase
{
use RefreshDatabase;
public function test_login_devolve_token_com_validade(): void
{
$usuario = Usuario::factory()->create(['senha' => Hash::make('segredo123')]);
$resposta = $this->postJson('/api/login', [
'email' => $usuario->email,
'senha' => 'segredo123',
])->assertCreated()->assertJsonStructure(['token', 'expira_em', 'usuario']);
$this->assertNotNull($resposta->json('expira_em'));
$this->assertDatabaseCount('personal_access_tokens', 1);
}
public function test_rota_protegida_exige_token(): void
{
$this->getJson('/api/me')->assertUnauthorized();
}
public function test_token_expirado_e_recusado(): void
{
$usuario = Usuario::factory()->create();
$token = $usuario->createToken('teste', ['*'], now()->addDay());
// Viaja 25 horas: o token de 24h deixou de valer.
$this->travel(25)->hours();
$this->withHeader('Authorization', "Bearer {$token->plainTextToken}")
->getJson('/api/me')
->assertUnauthorized();
}
public function test_habilidade_insuficiente_devolve_403(): void
{
// Sanctum::actingAs recebe as abilities do token — é assim que se
// testa a restrição sem montar o login inteiro.
Sanctum::actingAs(Usuario::factory()->create(), ['produtos:ler']);
$this->postJson('/api/produtos', ['nome' => 'X'])->assertForbidden();
}
public function test_logout_revoga_apenas_o_token_atual(): void
{
$usuario = Usuario::factory()->create();
$celular = $usuario->createToken('celular');
$usuario->createToken('desktop');
$this->withHeader('Authorization', "Bearer {$celular->plainTextToken}")
->postJson('/api/logout')
->assertOk();
// O desktop continua conectado.
$this->assertDatabaseCount('personal_access_tokens', 1);
$this->assertDatabaseHas('personal_access_tokens', ['name' => 'desktop']);
}
}
Exercício 3
Configure o handler de erros para retornar JSON consistente em todos os cenários (404, 401, 403, 422, 500) com a estrutura { mensagem, erros? }.
Ver resposta
✓ Resposta: O vazamento silencioso está na mensagem padrão do ModelNotFoundException: "No query results for model [App\Models\Produto] 42" entrega a estrutura interna para quem só mandou um id inexistente. Mesma história no 500 — getMessage() de exceção não tratada carrega caminho de arquivo, SQL e, com sorte do atacante, credencial. O ganho do formato único { mensagem, erros? } é do outro lado da linha: o cliente escreve um tratador em vez de um if por status.
<?php
// ---------------------------------------- bootstrap/app.php (Laravel 11+)
declare(strict_types=1);
use App\Exceptions\ErroDeNegocio;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $excecoes): void {
// shouldRenderJsonWhen decide QUANDO responder JSON. O padrão já
// cobre expectsJson(); esta versão acrescenta todo /api/*, para o
// cliente que esqueceu o header Accept receber JSON assim mesmo —
// em vez de uma página de erro HTML dentro de um fetch().
$excecoes->shouldRenderJsonWhen(function (Request $request, Throwable $e): bool {
return $request->is('api/*') || $request->expectsJson();
});
// A ESTRUTURA ÚNICA de erro da API: { mensagem, erros? }. Um
// formato só, em todos os cenários, é o que permite ao cliente ter
// um único tratador em vez de um if por status.
$excecoes->render(function (Throwable $e, Request $request) {
if (!$request->is('api/*') && !$request->expectsJson()) {
return null; // segue o fluxo normal (página de erro HTML)
}
[$status, $corpo] = match (true) {
$e instanceof ValidationException => [422, [
'mensagem' => 'Os dados enviados são inválidos.',
'erros' => $e->errors(),
]],
$e instanceof AuthenticationException => [401, [
'mensagem' => 'Autenticação necessária.',
]],
$e instanceof AuthorizationException => [403, [
// A mensagem da Policy, quando ela deu uma — senão, o
// texto genérico.
'mensagem' => $e->getMessage() !== ''
? $e->getMessage()
: 'Você não tem permissão para esta ação.',
]],
// ModelNotFound vaza o nome da classe na mensagem padrão
// ("No query results for model [App\Models\Produto] 42").
// Aqui vira um 404 que não conta nada da implementação.
$e instanceof ModelNotFoundException,
$e instanceof NotFoundHttpException => [404, [
'mensagem' => 'Recurso não encontrado.',
]],
$e instanceof TooManyRequestsHttpException => [429, [
'mensagem' => 'Muitas requisições. Tente de novo em instantes.',
]],
// Exceção de domínio da aplicação, com código estável.
$e instanceof ErroDeNegocio => [$e->statusHttp(), [
'mensagem' => $e->getMessage(),
'codigo' => $e->codigo(),
]],
// 405, 415 e afins: o Symfony já traz o status certo.
$e instanceof HttpExceptionInterface => [$e->getStatusCode(), [
'mensagem' => $e->getMessage() !== ''
? $e->getMessage()
: 'Requisição não pôde ser processada.',
]],
default => [500, [
// NUNCA getMessage() de exceção não tratada em
// produção: ela carrega caminho de arquivo, SQL e, com
// sorte do atacante, credencial.
'mensagem' => 'Erro interno. A equipe já foi notificada.',
]],
};
// Em desenvolvimento, o detalhe técnico vem junto — é o que
// torna o handler utilizável em vez de irritante.
if ($status === 500 && config('app.debug')) {
$corpo['debug'] = [
'excecao' => $e::class,
'mensagem' => $e->getMessage(),
'arquivo' => $e->getFile() . ':' . $e->getLine(),
'trace' => collect($e->getTrace())->take(5)->all(),
];
}
// O id do erro amarra a resposta ao log: o usuário manda o
// código no chamado e o suporte acha a linha em um grep.
$corpo['erro_id'] = $request->header('X-Request-Id')
?? (string) \Illuminate\Support\Str::uuid();
return response()->json($corpo, $status);
});
// Não polua o log com o que é fluxo normal: 422 e 404 acontecem o
// tempo todo e não são incidente.
$excecoes->dontReport([
ValidationException::class,
ModelNotFoundException::class,
AuthorizationException::class,
]);
// Contexto que acompanha TODO relatório de erro.
$excecoes->context(fn (): array => [
'usuario_id' => auth()->id(),
'url' => request()->fullUrl(),
'ip' => request()->ip(),
]);
})
->create();
// ---------------------------------------- app/Exceptions/ErroDeNegocio.php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
/**
* A raiz das regras de negócio. Ter uma classe própria é o que permite ao
* handler distinguir "o pedido violou uma regra" (que o cliente pode
* corrigir) de "algo quebrou" (que é problema nosso).
*/
abstract class ErroDeNegocio extends RuntimeException
{
abstract public function codigo(): string;
public function statusHttp(): int
{
return 422;
}
}
// ---------------------------------------- app/Exceptions/EstoqueInsuficiente.php
declare(strict_types=1);
namespace App\Exceptions;
final class EstoqueInsuficiente extends ErroDeNegocio
{
public function __construct(
public readonly string $produto,
public readonly int $disponivel,
public readonly int $solicitado,
) {
parent::__construct(
"Estoque insuficiente de \"{$produto}\": há {$disponivel}, "
. "foram pedidas {$solicitado}."
);
}
public function codigo(): string
{
return 'ESTOQUE_INSUFICIENTE';
}
public function statusHttp(): int
{
return 409; // conflito com o estado atual do recurso
}
}
// ---------------------------------------- tests/Feature/Api/ErrosTest.php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Produto;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class ErrosTest extends TestCase
{
use RefreshDatabase;
public function test_404_em_json(): void
{
$this->getJson('/api/produtos/999999')
->assertNotFound()
->assertJsonStructure(['mensagem', 'erro_id'])
// A mensagem padrão vazaria "App\Models\Produto".
->assertJsonMissing(['mensagem' => 'No query results for model [App\Models\Produto] 999999']);
}
public function test_401_sem_token(): void
{
$this->getJson('/api/me')
->assertUnauthorized()
->assertJsonPath('mensagem', 'Autenticação necessária.');
}
public function test_422_traz_os_erros_por_campo(): void
{
$admin = Usuario::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin, 'sanctum')
->postJson('/api/produtos', [])
->assertStatus(422)
->assertJsonStructure(['mensagem', 'erros' => ['nome', 'preco', 'categoria_id']]);
}
public function test_500_nao_vaza_detalhe_em_producao(): void
{
config(['app.debug' => false]);
Route::get('/api/explode', fn () => throw new \RuntimeException('senha do banco: 123'));
$this->getJson('/api/explode')
->assertStatus(500)
->assertJsonPath('mensagem', 'Erro interno. A equipe já foi notificada.')
->assertJsonMissing(['debug'])
// O teste que importa: o texto secreto não está em lugar nenhum
// da resposta.
->assertDontSee('senha do banco');
}
public function test_erro_de_negocio_vira_409_com_codigo(): void
{
Route::get('/api/estoque', fn () => throw new \App\Exceptions\EstoqueInsuficiente('Caneca', 2, 5));
$this->getJson('/api/estoque')
->assertStatus(409)
->assertJsonPath('codigo', 'ESTOQUE_INSUFICIENTE');
}
public function test_toda_resposta_de_erro_tem_a_mesma_forma(): void
{
foreach (['/api/produtos/999999', '/api/me'] as $url) {
$this->getJson($url)->assertJsonStructure(['mensagem', 'erro_id']);
}
}
}
Exercício 4
Desafio: implemente versionamento de API: v1 e v2 com namespaces e prefixos separados. No v2, adicione um campo descricao_curta no ProdutoResource (primeiros 100 chars da descrição) sem alterar o v1.
Ver resposta
✓ Resposta: A regra que decide tudo: acrescentar campo não quebra cliente; remover, renomear ou mudar o tipo, sim. Por isso a v2 pôde ganhar estoque e descricao_curta mantendo tudo o que a v1 tinha — e por isso o teste que compara os dois formatos campo a campo é o mais valioso do arquivo. A herança entre Resources evita o arquivo duplicado de 80 linhas onde a diferença real são cinco. E aposentar versão é processo, não deploy: os headers Deprecation e Sunset avisam meses antes, e o log mostra quem ainda não migrou.
<?php
// ============ estrutura ============
// app/Http/
// ├── Controllers/Api/
// │ ├── V1/ProdutoController.php
// │ └── V2/ProdutoController.php ← estende o V1, sobrescreve o que muda
// └── Resources/
// ├── V1/ProdutoResource.php
// └── V2/ProdutoResource.php ← idem
// routes/
// ├── api_v1.php
// └── api_v2.php
//
// O princípio: a v1 é CONGELADA. Nada nela muda — nem uma vírgula — porque
// há um app publicado na loja consumindo aquele formato, e ele não atualiza
// quando você faz deploy.
// ---------------------------------------- bootstrap/app.php (trecho)
declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
// Cada versão tem seu arquivo e seu prefixo. Ficam totalmente
// independentes: mexer numa não recompila a outra.
then: function (): void {
Route::middleware('api')
->prefix('api/v1')
->name('api.v1.')
->group(base_path('routes/api_v1.php'));
Route::middleware('api')
->prefix('api/v2')
->name('api.v2.')
->group(base_path('routes/api_v2.php'));
},
)
->create();
// ---------------------------------------- routes/api_v1.php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\ProdutoController;
use Illuminate\Support\Facades\Route;
// CONGELADO. Não altere — há clientes em produção dependendo deste formato.
Route::apiResource('produtos', ProdutoController::class);
// ---------------------------------------- routes/api_v2.php
declare(strict_types=1);
use App\Http\Controllers\Api\V2\ProdutoController;
use Illuminate\Support\Facades\Route;
Route::apiResource('produtos', ProdutoController::class);
// ---------------------------------------- app/Http/Resources/V1/ProdutoResource.php
declare(strict_types=1);
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Produto */
class ProdutoResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nome' => $this->nome,
'slug' => $this->slug,
'descricao' => $this->descricao,
'preco' => (float) $this->preco,
'disponivel' => $this->estoque > 0,
'criado_em' => $this->created_at?->toIso8601String(),
];
}
public function with(Request $request): array
{
return ['api' => ['versao' => 'v1']];
}
}
// ---------------------------------------- app/Http/Resources/V2/ProdutoResource.php
declare(strict_types=1);
namespace App\Http\Resources\V2;
use App\Http\Resources\V1\ProdutoResource as ProdutoResourceV1;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
/**
* Herda a v1 e acrescenta. Assim, campo novo que sirva às duas entra uma
* vez só — e o que a v2 muda fica visível em cinco linhas, em vez de
* escondido num arquivo duplicado de 80.
*
* @mixin \App\Models\Produto
*/
class ProdutoResource extends ProdutoResourceV1
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
...parent::toArray($request),
// A novidade da v2.
'descricao_curta' => Str::limit(strip_tags((string) $this->descricao), 100),
// Compatibilidade PARA A FRENTE: campos novos podem ser
// acrescentados numa versão existente sem quebrar ninguém —
// cliente bem escrito ignora chave que não conhece. O que
// quebra é REMOVER campo, RENOMEAR ou mudar o TIPO. Por isso
// `estoque` (novo) pôde entrar aqui, mas `disponivel` (antigo)
// continua saindo.
'estoque' => (int) $this->estoque,
];
}
public function with(Request $request): array
{
return ['api' => ['versao' => 'v2']];
}
}
// ---------------------------------------- app/Http/Controllers/Api/V1/ProdutoController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\ProdutoResource;
use App\Models\Produto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProdutoController extends Controller
{
/** O Resource usado fica num método, para a v2 poder trocá-lo. */
protected function resource(): string
{
return ProdutoResource::class;
}
public function index(Request $request): AnonymousResourceCollection
{
$produtos = Produto::query()
->with('categoria')
->paginate(min($request->integer('per_page', 20), 100));
return $this->resource()::collection($produtos);
}
public function show(Produto $produto): mixed
{
$resource = $this->resource();
return new $resource($produto->load('categoria'));
}
}
// ---------------------------------------- app/Http/Controllers/Api/V2/ProdutoController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V2;
use App\Http\Controllers\Api\V1\ProdutoController as ProdutoControllerV1;
use App\Http\Resources\V2\ProdutoResource;
use App\Models\Produto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProdutoController extends ProdutoControllerV1
{
// A troca do Resource é TODA a diferença entre as versões — enquanto
// for só isso, herdar é mais honesto que duplicar.
protected function resource(): string
{
return ProdutoResource::class;
}
/**
* Quando o comportamento diverge de verdade, sobrescreva o método
* inteiro. Aqui: a v2 aceita ordenação, que a v1 não tinha.
*/
public function index(Request $request): AnonymousResourceCollection
{
$ordenaveis = ['nome', 'preco', 'created_at'];
$ordem = in_array($request->string('ordenar')->value(), $ordenaveis, true)
? $request->string('ordenar')->value()
: 'nome';
$produtos = Produto::query()
->with('categoria')
->orderBy($ordem, $request->string('direcao')->value() === 'desc' ? 'desc' : 'asc')
->paginate(min($request->integer('per_page', 20), 100));
return ProdutoResource::collection($produtos);
}
}
// ---------------------------------------- app/Http/Middleware/AvisarVersaoObsoleta.php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Aposentar uma versão é processo, não deploy. Os headers Deprecation e
* Sunset (RFC 8594) avisam o cliente MESES antes, e o log mostra quem
* ainda não migrou — sem isso, desligar a v1 é adivinhação.
*/
class AvisarVersaoObsoleta
{
public function handle(Request $request, Closure $proximo, string $desligaEm): Response
{
$resposta = $proximo($request);
$resposta->headers->set('Deprecation', 'true');
$resposta->headers->set('Sunset', $desligaEm);
$resposta->headers->set('Link', '</api/v2/produtos>; rel="successor-version"');
return $resposta;
}
}
// Em routes/api_v1.php:
// Route::middleware('versao.obsoleta:Sat, 31 Dec 2026 23:59:59 GMT')->group(...)
// ---------------------------------------- tests/Feature/Api/VersionamentoTest.php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Produto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class VersionamentoTest extends TestCase
{
use RefreshDatabase;
public function test_v1_nao_tem_o_campo_novo(): void
{
Produto::factory()->create(['descricao' => str_repeat('a', 300)]);
$this->getJson('/api/v1/produtos')
->assertOk()
->assertJsonPath('api.versao', 'v1')
// O teste que protege o contrato: se alguém acrescentar o
// campo na v1 sem querer, a suíte avisa.
->assertJsonMissingPath('data.0.descricao_curta');
}
public function test_v2_tem_descricao_curta_de_100_caracteres(): void
{
Produto::factory()->create(['descricao' => str_repeat('a', 300)]);
$resposta = $this->getJson('/api/v2/produtos')->assertOk();
$resposta->assertJsonPath('api.versao', 'v2');
// Str::limit conta o "..." dentro do limite: 97 + 3 = 100.
$this->assertSame(100, strlen($resposta->json('data.0.descricao_curta')));
}
public function test_as_duas_versoes_respondem_ao_mesmo_tempo(): void
{
$produto = Produto::factory()->create();
$v1 = $this->getJson('/api/v1/produtos')->json('data.0');
$v2 = $this->getJson('/api/v2/produtos')->json('data.0');
// Os campos da v1 continuam idênticos na v2 — acrescentar não
// quebra ninguém; remover, renomear ou trocar o tipo, sim.
foreach (array_keys($v1) as $campo) {
$this->assertArrayHasKey($campo, $v2, "A v2 perdeu o campo {$campo}");
$this->assertSame($v1[$campo], $v2[$campo]);
}
}
public function test_ordenacao_e_exclusiva_da_v2(): void
{
Produto::factory()->create(['nome' => 'Zebra']);
Produto::factory()->create(['nome' => 'Abacate']);
$this->getJson('/api/v2/produtos?ordenar=nome&direcao=desc')
->assertJsonPath('data.0.nome', 'Zebra');
}
}
Exercício 5
O Sanctum atende dois clientes muito diferentes com a mesma string de middleware auth:sanctum: o app mobile, que manda Authorization: Bearer ..., e a SPA no mesmo domínio, que manda o cookie de sessão. Descubra o que $request->user()->currentAccessToken() devolve em cada um dos dois casos e teste o logout() deste artigo nos dois. Depois responda: por que o token do modo SPA aceita ->can('qualquer-coisa') e devolve true?
Ver resposta
✓ Resposta: No modo bearer vem um PersonalAccessToken, que é um model Eloquent de verdade, com linha na tabela personal_access_tokens e portanto com delete(). No modo SPA vem um TransientToken, que não é model nenhum — a classe inteira tem dois métodos, can() e cant(). Chamar ->delete() nele produz Error: Call to undefined method Laravel\Sanctum\TransientToken::delete(), e o logout() escrito sem a verificação derruba a rota justamente para o cliente que mais a usa.
A decisão está no Guard do próprio pacote, e ler as primeiras linhas dele explica tudo:
<?php
// vendor/laravel/sanctum/src/Guard.php
public function __invoke(Request $request)
{
// Primeiro: alguém já está logado pela SESSÃO? Então é a SPA.
foreach (Arr::wrap(config('sanctum.guard', 'web')) as $guard) {
if ($user = $this->auth->guard($guard)->user()) {
return $this->supportsTokens($user)
? $user->withAccessToken(new TransientToken) // ← sem linha no banco
: $user;
}
}
// Só então procura o Bearer no cabeçalho.
if ($token = $this->getTokenFromRequest($request)) { /* PersonalAccessToken */ }
}
Daí sai a resposta da segunda pergunta. O TransientToken::can() devolve true para qualquer habilidade, sempre, e isso é deliberado: a SPA é a sua própria aplicação, autenticada pela sessão do navegador, e restringir habilidades ali não faria sentido — o usuário tem, por definição, todas as que a conta dele tem. Habilidade é um conceito do token emitido para um terceiro, não da sessão. A consequência prática merece atenção: $request->user()->tokenCan('admin') como única barreira de uma rota administrativa passa direto para qualquer requisição vinda da SPA. Habilidade de token restringe o que aquele token pode fazer; quem decide o que o usuário pode fazer continua sendo a policy.
O logout() correto trata os dois caminhos em vez de supor um:
<?php
public function logout(Request $request): JsonResponse
{
$token = $request->user()->currentAccessToken();
if ($token instanceof PersonalAccessToken) {
$token->delete(); // app mobile: revoga este token
} else {
Auth::guard('web')->logout(); // SPA: encerra a sessão
$request->session()->invalidate();
$request->session()->regenerateToken();
}
return response()->json(['mensagem' => 'Logout realizado.']);
}
Vale guardar a regra geral, que vai além do Sanctum: middleware que aceita mais de uma forma de autenticação entrega objetos de tipos diferentes ao controller, e o código que assume um deles só quebra no dia em que o outro cliente entra em produção. Quando um método pode devolver duas classes sem ancestral comum útil, o instanceof não é defensividade — é a leitura honesta do contrato.