Tema
APIs REST con Sanctum
Hoy muchos clientes piden "también una app móvil" o un frontend en React/Vue. La pieza común es una API: tu Laravel responde JSON en lugar de HTML. En esta lección construirás una API REST completa con rutas de API, API Resources para dar forma al JSON, paginación, validación y autenticación por tokens con Laravel Sanctum.
Concepto
Una API es como el mostrador de una cocina: el cliente (app móvil, React, otro servidor) pide algo con un formato acordado y recibe la respuesta en un formato acordado (JSON). No le importa cómo se cocina.
REST organiza la API en recursos y usa los verbos HTTP:
| Verbo | URL | Acción | Código de éxito |
|---|---|---|---|
| GET | /api/tasks | Listar | 200 |
| POST | /api/tasks | Crear | 201 |
| GET | /api/tasks/5 | Ver uno | 200 |
| PUT/PATCH | /api/tasks/5 | Actualizar | 200 |
| DELETE | /api/tasks/5 | Borrar | 204 |
Las APIs no usan cookies de sesión como las páginas normales (sobre todo las apps móviles). En su lugar, el cliente envía un token en cada petición: Authorization: Bearer 1|abc123.... Sanctum genera y valida esos tokens.
Sanctum, dos modos
- Tokens de API: para apps móviles y terceros (lo que veremos aquí).
- Autenticación SPA: para un frontend en el mismo dominio, usando la cookie de sesión. Más simple y seguro si tu React/Vue vive en el mismo dominio principal.
Paso 1: instalar la API
En Laravel 11/12 el archivo routes/api.php no existe por defecto. Un comando lo crea e instala Sanctum:
bash
php artisan install:apiEsto:
- Crea
routes/api.php(todas sus rutas llevan prefijo/api). - Instala
laravel/sanctumy publica la migraciónpersonal_access_tokens. - Registra las rutas de API en
bootstrap/app.php.
Luego añade el trait al modelo User:
php
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, Notifiable; // HasApiTokens añade createToken(), tokens()...
// ...
}Y migra si el comando no lo hizo: php artisan migrate.
Paso 2: modelo y migración de ejemplo
bash
php artisan make:model Task -mfphp
// database/migrations/xxxx_create_tasks_table.php
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->boolean('completed')->default(false);
$table->date('due_date')->nullable();
$table->timestamps();
});
}php
// app/Models/Task.php
class Task extends Model
{
use HasFactory;
protected $fillable = ['title', 'description', 'completed', 'due_date'];
protected function casts(): array
{
return [
'completed' => 'boolean',
'due_date' => 'date',
];
}
public function user()
{
return $this->belongsTo(User::class);
}
}En User añade public function tasks() { return $this->hasMany(Task::class); }.
Paso 3: API Resources
Un Resource decide exactamente qué campos salen en el JSON y con qué nombres. Así nunca expones columnas internas por accidente.
bash
php artisan make:resource TaskResourcephp
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'completed' => $this->completed,
'due_date' => $this->due_date?->format('Y-m-d'),
'is_overdue' => $this->due_date && ! $this->completed && $this->due_date->isPast(),
// Sólo se incluye si la relación se cargó con ->load('user')
'owner' => $this->whenLoaded('user', fn () => [
'id' => $this->user->id,
'name' => $this->user->name,
]),
'created_at' => $this->created_at->toIso8601String(),
];
}
}Paso 4: controlador de API
bash
php artisan make:controller Api/TaskController --api --model=Task--api genera sólo index, store, show, update y destroy (sin create/edit, que son formularios HTML).
php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TaskController extends Controller
{
// GET /api/tasks?completed=0&search=factura
public function index(Request $request)
{
$tasks = $request->user()->tasks()
// Filtros opcionales por query string
->when($request->has('completed'), fn ($q) => $q->where('completed', $request->boolean('completed')))
->when($request->search, fn ($q, $search) => $q->where('title', 'like', "%{$search}%"))
->latest()
->paginate($request->integer('per_page', 15));
// collection() + paginate añade "links" y "meta" automáticamente
return TaskResource::collection($tasks);
}
// POST /api/tasks
public function store(Request $request)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'due_date' => ['nullable', 'date'],
]);
$task = $request->user()->tasks()->create($data);
// 201 Created
return (new TaskResource($task))->response()->setStatusCode(Response::HTTP_CREATED);
}
// GET /api/tasks/{task}
public function show(Request $request, Task $task)
{
$this->ensureOwner($request, $task);
return new TaskResource($task->load('user'));
}
// PUT/PATCH /api/tasks/{task}
public function update(Request $request, Task $task)
{
$this->ensureOwner($request, $task);
// "sometimes": permite actualizar sólo algunos campos (PATCH)
$data = $request->validate([
'title' => ['sometimes', 'required', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string'],
'completed' => ['sometimes', 'boolean'],
'due_date' => ['sometimes', 'nullable', 'date'],
]);
$task->update($data);
return new TaskResource($task);
}
// DELETE /api/tasks/{task}
public function destroy(Request $request, Task $task)
{
$this->ensureOwner($request, $task);
$task->delete();
return response()->noContent(); // 204
}
private function ensureOwner(Request $request, Task $task): void
{
// 404 en lugar de 403 para no revelar que el recurso existe
abort_if($task->user_id !== $request->user()->id, 404);
}
}(En un proyecto grande usarías una Policy, como en la lección de autorización).
Paso 5: autenticación con tokens
Controlador para obtener y revocar tokens:
php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
// POST /api/login { email, password, device_name }
public function login(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required', 'string', 'max:100'], // "iPhone de Ana"
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Las credenciales no son correctas.'],
]);
}
// Habilidades (abilities): permisos del token
$token = $user->createToken($request->device_name, ['tasks:read', 'tasks:write']);
return response()->json([
'token' => $token->plainTextToken, // sólo se muestra UNA vez
'user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email],
]);
}
// POST /api/logout (revoca el token actual)
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
// GET /api/me
public function me(Request $request)
{
return response()->json($request->user()->only('id', 'name', 'email'));
}
}Paso 6: rutas
php
<?php
// routes/api.php (todas con prefijo /api)
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\TaskController;
use Illuminate\Support\Facades\Route;
// Públicas, con límite de 5 intentos por minuto
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1');
// Protegidas: requieren "Authorization: Bearer <token>"
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', [AuthController::class, 'me']);
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('tasks', TaskController::class);
});Verifica con php artisan route:list --path=api.
Probar la API
Con curl (o Postman, Insomnia, Thunder Client en VS Code):
bash
# 1. Login: obtén el token
curl -X POST http://miapp.test/api/login \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password","device_name":"curl"}'
# Respuesta: {"token":"1|q8Wm...","user":{...}}
# 2. Crear una tarea
curl -X POST http://miapp.test/api/tasks \
-H "Accept: application/json" \
-H "Authorization: Bearer 1|q8Wm..." \
-H "Content-Type: application/json" \
-d '{"title":"Enviar cotización","due_date":"2026-11-01"}'
# 3. Listar
curl http://miapp.test/api/tasks?completed=0 \
-H "Accept: application/json" \
-H "Authorization: Bearer 1|q8Wm..."Respuesta paginada:
json
{
"data": [
{
"id": 1,
"title": "Enviar cotización",
"description": null,
"completed": false,
"due_date": "2026-11-01",
"is_overdue": false,
"created_at": "2026-10-18T10:00:00+00:00"
}
],
"links": { "first": "...?page=1", "last": "...?page=1", "prev": null, "next": null },
"meta": { "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 }
}Desde JavaScript:
js
const res = await fetch('https://miapp.com/api/tasks', {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (res.status === 401) {
// token inválido o revocado: mandar a login
}
const { data, meta } = await res.json();Error común
Olvidar la cabecera Accept: application/json. Sin ella, un error de validación devuelve una redirección 302 (comportamiento web) en lugar del JSON 422, y un usuario no autenticado es redirigido a /login en vez de recibir 401.
Abilities (permisos del token)
php
// Token de sólo lectura para una integración externa
$token = $user->createToken('reporte-contabilidad', ['tasks:read']);
// En el controlador
if (! $request->user()->tokenCan('tasks:write')) {
abort(403, 'Este token no puede modificar tareas.');
}Para comprobarlo con middleware, registra los alias en bootstrap/app.php:
php
$middleware->alias([
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
]);php
Route::post('/tasks', [TaskController::class, 'store'])->middleware(['auth:sanctum', 'abilities:tasks:write']);Expiración de tokens
Por defecto los tokens no caducan. En config/sanctum.php:
php
'expiration' => 60 * 24 * 30, // minutos: 30 díasY programa la limpieza de tokens vencidos en routes/console.php:
php
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();Buena práctica
- Versiona tu API desde el inicio (
Route::prefix('v1')) para poder cambiarla sin romper apps publicadas. - Usa siempre Resources: nunca devuelvas un modelo directo (
return $user;podría exponer columnas sensibles si alguien olvida$hidden). - Si tu frontend está en otro dominio, configura CORS en
config/cors.php(php artisan config:publish cors). Ver CORS.
Ejercicios
- Añade un endpoint
PATCH /api/tasks/{task}/toggleque invierta el campocompletedy devuelva la tarea. - Crea un endpoint
POST /api/registerque cree el usuario y devuelva un token directamente.
Ver solución 1
php
// routes/api.php, dentro del grupo auth:sanctum
Route::patch('/tasks/{task}/toggle', [TaskController::class, 'toggle']);
// TaskController
public function toggle(Request $request, Task $task)
{
$this->ensureOwner($request, $task);
$task->update(['completed' => ! $task->completed]);
return new TaskResource($task);
}Ver solución 2
php
use Illuminate\Validation\Rules\Password;
public function register(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'device_name' => ['required', 'string'],
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'], // cast 'hashed'
]);
return response()->json([
'token' => $user->createToken($data['device_name'])->plainTextToken,
'user' => $user->only('id', 'name', 'email'),
], 201);
}
// routes/api.php
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:5,1');Resumen
php artisan install:apicrearoutes/api.phpe instala Sanctum en Laravel 11/12.- Añade
HasApiTokensal modeloUser. - Controladores con
--apiy rutas conRoute::apiResource(). - API Resources controlan el JSON; con paginación añaden
linksymeta. - Tokens:
createToken()->plainTextToken; protege rutas conauth:sanctum. - Envía siempre
Accept: application/jsonyAuthorization: Bearer .... - Usa abilities, expiración, throttle y versionado para una API profesional.