APIs com Laravel

APIs com Laravel

API Resources, Sanctum, tratamento de erro em JSON e rate limiting, montados na ordem em que um endpoint precisa deles. Aponta as três mudanças de esqueleto do Laravel 11 que quebram o código antigo e o logout que falha quando o cliente é uma SPA, não um app.
PHP

• • 35 min de leitura

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

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.

Comentários

Mais em PHP

Variáveis, Tipos de Dados e Operadores
Variáveis, Tipos de Dados e Operadores

Toda variável em PHP guarda um valor de algum tipo, e a linguagem converte…

PHP 8.x Moderno
PHP 8.x Moderno

Enums, readonly, atributos e fibers mudaram o que se considera código PHP…

Funções
Funções

Uma função guarda um trecho de lógica sob um nome e um contrato. Como declarar…