Curso de Laravel

API REST en Laravel: rutas, recursos y autenticación con tokens

Por Víctor Peña · Publicado el

Hola, ¿cómo están? Cerramos el módulo de Laravel avanzado con las APIs.

Tarde o temprano el sistema tiene que hablar con algo que no es un navegador: una aplicación móvil, otro sistema de la empresa, un panel hecho en otra tecnología. Ahí ya no sirven las vistas Blade: hace falta devolver datos.

¡Empecemos!

Qué cambia respecto a la web

Aplicación web API
Devuelve HTML Devuelve JSON
Sesiones y cookies Tokens
Redirecciones Códigos de estado
@csrf No aplica
Errores en la vista Errores en JSON con 422

La lógica es la misma. Cambia cómo entra y sale la información.

Habilitar las rutas de API

En un proyecto nuevo, routes/api.php no existe. Se crea con:

php artisan install:api

Ese comando hace tres cosas: crea el archivo de rutas, lo registra en bootstrap/app.php e instala Sanctum con su migración de tokens.

php artisan migrate

Las rutas de ese archivo llevan automáticamente el prefijo /api, así que Route::get('/pacientes') responde en /api/pacientes.

Las rutas

use App\Http\Controllers\Api\PacienteController;

Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('pacientes', PacienteController::class);
    Route::apiResource('citas', CitaController::class);

    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/perfil', fn (Request $r) => $r->user());
});

apiResource en lugar de resource. Registra las mismas rutas menos create y edit, que devolvían formularios HTML y en una API no tienen sentido. Cinco rutas en vez de siete.

php artisan route:list --path=api

API Resources: cómo se devuelven los datos

Se puede devolver el modelo directamente:

return response()->json($paciente);

Pero eso expone todas las columnas, incluidas las que no deberían salir, y ata el formato de la respuesta a la estructura de la tabla. Renombrar una columna rompe la aplicación móvil.

La forma correcta:

php artisan make:resource PacienteResource
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class PacienteResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'cedula' => $this->cedula,
            'nombre_completo' => "{$this->nombre} {$this->apellido}",
            'telefono' => $this->telefono,
            'edad' => $this->fecha_nacimiento->age,
            'foto' => $this->foto ? Storage::url($this->foto) : null,

            'citas' => CitaResource::collection($this->whenLoaded('citas')),
            'total_citas' => $this->whenCounted('citas'),

            'creado_en' => $this->created_at->toIso8601String(),
        ];
    }
}

Tres detalles útiles:

whenLoaded() incluye la relación solo si se cargó. Sin eso, cada elemento del listado lanzaría una consulta: el problema N+1 dentro de la respuesta JSON.

Las fechas en formato ISO 8601. Es lo que cualquier cliente sabe interpretar. Un d/m/Y obliga a la aplicación móvil a adivinar el formato.

Los datos calculados se resuelven aquí, no en el cliente.

Y se usa así:

return new PacienteResource($paciente);

return PacienteResource::collection($pacientes);

Los recursos se convierten a JSON automáticamente.

El controlador de API

class PacienteController extends Controller
{
    public function index(Request $request)
    {
        $pacientes = Paciente::query()
            ->when($request->buscar, fn ($q, $v) =>
                $q->where('nombre', 'like', "%{$v}%"))
            ->withCount('citas')
            ->orderBy('apellido')
            ->paginate($request->integer('por_pagina', 15));

        return PacienteResource::collection($pacientes);
    }

    public function store(GuardarPacienteRequest $request)
    {
        $paciente = Paciente::create($request->validated());

        return (new PacienteResource($paciente))
            ->response()
            ->setStatusCode(201);
    }

    public function show(Paciente $paciente)
    {
        $paciente->load('citas.doctor');

        return new PacienteResource($paciente);
    }

    public function update(ActualizarPacienteRequest $request, Paciente $paciente)
    {
        $paciente->update($request->validated());

        return new PacienteResource($paciente);
    }

    public function destroy(Paciente $paciente)
    {
        $paciente->delete();

        return response()->noContent();
    }
}

Los mismos Form Requests de la web funcionan aquí. Laravel detecta que la petición espera JSON y devuelve los errores en JSON con código 422, sin que cambies nada. Es una de las cosas mejor resueltas del framework.

Y fíjate en que al paginar, collection() incluye automáticamente los metadatos:

{
  "data": [ ... ],
  "links": { "first": "...", "next": "..." },
  "meta": { "current_page": 1, "total": 248, "per_page": 15 }
}

Códigos de estado

Esto es lo que más se hace mal en las APIs. Devolver 200 con {"error": "no encontrado"} obliga al cliente a leer el cuerpo para saber si funcionó.

Código Cuándo
200 Todo bien
201 Se creó un recurso
204 Bien, sin contenido que devolver
400 Petición mal formada
401 Sin autenticar
403 Autenticado pero sin permiso
404 No existe
422 Validación fallida
429 Demasiadas peticiones
500 Error del servidor

401 y 403 se confunden constantemente: 401 es «no sé quién eres», 403 es «sé quién eres y no puedes».

Laravel ya devuelve la mayoría correctamente: 404 desde la vinculación de modelos, 403 desde authorize(), 422 desde la validación.

Autenticación con Sanctum

En el modelo User:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}

El controlador de acceso:

public function login(Request $request)
{
    $datos = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
        'dispositivo' => ['required', 'string'],
    ]);

    $usuario = User::where('email', $datos['email'])->first();

    if (! $usuario || ! Hash::check($datos['password'], $usuario->password)) {
        throw ValidationException::withMessages([
            'email' => [__('auth.failed')],
        ]);
    }

    return response()->json([
        'token' => $usuario->createToken($datos['dispositivo'])->plainTextToken,
        'usuario' => new UserResource($usuario),
    ]);
}

public function logout(Request $request)
{
    $request->user()->currentAccessToken()->delete();

    return response()->noContent();
}

Ese dispositivo permite que el usuario vea sus sesiones activas y cierre una en concreto, que en una aplicación móvil se agradece.

El cliente manda el token en cada petición:

Authorization: Bearer 1|aBcDeF...

El token completo solo se ve una vez. En la base de datos se guarda cifrado, exactamente igual que una contraseña. Si el usuario lo pierde, se genera otro.

Permisos por token

$usuario->createToken('app-movil', ['citas:leer', 'citas:crear']);
Route::middleware(['auth:sanctum', 'abilities:citas:crear'])->post('/citas', ...);
if ($request->user()->tokenCan('citas:eliminar')) {
    // ...
}

Útil cuando una integración externa solo debe poder leer.

Caducidad

En config/sanctum.php:

'expiration' => 60 * 24 * 30,   // 30 días

Y una limpieza periódica, con lo que vimos en la lección de tareas programadas:

Schedule::command('sanctum:prune-expired --hours=24')->daily();

Autorización

Las policies funcionan igual que en la web:

public function __construct()
{
    $this->authorizeResource(Paciente::class, 'paciente');
}

Un 403 sale en JSON automáticamente.

Limitar peticiones

Sin límite, cualquiera puede saturar la API:

Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
    // ...
});

Y para límites distintos por usuario, en AppServiceProvider:

RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(20)->by($request->ip());
});

Laravel devuelve un 429 con las cabeceras que indican cuánto queda.

En el acceso, el límite debe ser mucho más bajo:

Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1');

Errores en JSON

Por defecto Laravel devuelve las excepciones en JSON si la petición lo pide. Para dar un formato uniforme, en bootstrap/app.php:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ModelNotFoundException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'El recurso solicitado no existe.',
            ], 404);
        }
    });
})

Y una advertencia de seguridad: con APP_DEBUG=true, un error 500 devuelve la traza completa, con rutas del servidor y a veces credenciales. En producción, APP_DEBUG=false, siempre.

CORS

Si la API la consume una aplicación web en otro dominio, el navegador la bloquea sin la configuración adecuada:

php artisan config:publish cors
'allowed_origins' => ['https://app.miclinica.com'],

No pongas ['*'] en producción. Es cómodo mientras desarrollas y una mala idea después.

Esto no aplica a aplicaciones móviles ni a llamadas servidor a servidor: CORS es una restricción del navegador.

Versionar

Cuando ya hay clientes usando la API, cambiar el formato de la respuesta rompe sus aplicaciones. Y no puedes obligar a todos a actualizar el mismo día.

Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));

Empieza con /v1 desde el primer día, aunque nunca llegue a haber una v2. Añadirlo después, con clientes en producción, es mucho más molesto.

Probar la API

public function test_puede_listar_pacientes(): void
{
    $usuario = User::factory()->create();
    Paciente::factory()->count(3)->create();

    $respuesta = $this->actingAs($usuario, 'sanctum')
        ->getJson('/api/pacientes');

    $respuesta->assertOk()
        ->assertJsonCount(3, 'data')
        ->assertJsonStructure([
            'data' => [['id', 'cedula', 'nombre_completo']],
            'meta' => ['total'],
        ]);
}

actingAs($usuario, 'sanctum') autentica sin generar un token real.

Las APIs son más fáciles de probar que las vistas, porque la respuesta es un JSON con estructura conocida. Vale la pena aprovecharlo.

Documentar

Una API sin documentación no la usa nadie, ni siquiera tú dentro de seis meses.

Lo mínimo: un archivo con cada ruta, su método, sus parámetros y un ejemplo de respuesta. Hay herramientas que la generan desde el código o desde las pruebas, y colecciones compartibles en los clientes HTTP habituales.

Lo importante es que exista y esté al día. Una documentación desactualizada es peor que ninguna.

Errores comunes

  • Devolver el modelo directo en lugar de un API Resource.
  • 200 para todo, con el error dentro del cuerpo.
  • Confundir 401 y 403.
  • Olvidar install:api y no encontrar routes/api.php.
  • APP_DEBUG=true en producción.
  • allowed_origins con *.
  • Sin límite de peticiones, sobre todo en el acceso.
  • Relaciones sin whenLoaded(), provocando N+1.
  • No versionar desde el principio.

Para cerrar

Construir una API en Laravel reutiliza casi todo lo del curso: los mismos modelos, los mismos Form Requests, las mismas policies. Lo que cambia es la capa de salida.

Tres cosas que marcan la diferencia entre una API decente y una que da problemas: API Resources en lugar de modelos crudos, códigos de estado correctos y límite de peticiones desde el primer día.

Con esto cerramos Laravel avanzado. En la siguiente lección empezamos con los reportes, que es lo que todo sistema de gestión termina pidiendo.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp