Skip to content

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:

VerboURLAcciónCódigo de éxito
GET/api/tasksListar200
POST/api/tasksCrear201
GET/api/tasks/5Ver uno200
PUT/PATCH/api/tasks/5Actualizar200
DELETE/api/tasks/5Borrar204

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

  1. Tokens de API: para apps móviles y terceros (lo que veremos aquí).
  2. 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:api

Esto:

  • Crea routes/api.php (todas sus rutas llevan prefijo /api).
  • Instala laravel/sanctum y publica la migración personal_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 -mf
php
// 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 TaskResource
php
<?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ías

Y 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 ​

  1. Añade un endpoint PATCH /api/tasks/{task}/toggle que invierta el campo completed y devuelva la tarea.
  2. Crea un endpoint POST /api/register que 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:api crea routes/api.php e instala Sanctum en Laravel 11/12.
  • Añade HasApiTokens al modelo User.
  • Controladores con --api y rutas con Route::apiResource().
  • API Resources controlan el JSON; con paginación añaden links y meta.
  • Tokens: createToken()->plainTextToken; protege rutas con auth:sanctum.
  • Envía siempre Accept: application/json y Authorization: Bearer ....
  • Usa abilities, expiración, throttle y versionado para una API profesional.

Academia Web — aprende, copia y construye.