Una API REST mal diseñada no falla de golpe. Falla gradualmente: cada nuevo endpoint es un poco más difícil de agregar que el anterior, la documentación empieza a quedar desactualizada, los errores son inconsistentes según quién escribió cada ruta, y eventualmente el equipo empieza a hablar de "reescribir la API" como si fuera una solución. Casi nunca lo es.

La mayoría de los problemas de mantenibilidad en APIs Laravel no vienen de limitaciones del framework. Vienen de decisiones de diseño tomadas al principio del proyecto que nunca fueron revisadas. Este artículo cubre las que más impacto tienen.


Estructura de respuesta consistente desde el día uno

El error más costoso de corregir después es la inconsistencia en el formato de respuesta. Un endpoint que devuelve {"data": [...], "total": 10}, otro que devuelve directamente el array, y un tercero que devuelve {"result": {...}, "ok": true} hacen que cada integración sea una aventura de descubrimiento.

La solución es definir un wrapper de respuesta antes de escribir el primer endpoint y usarlo en absolutamente todos los casos. Laravel tiene la infraestructura para hacerlo sin repetición:

app/Http/Resources/ApiResource.php
class ApiResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'data'    => $this->resource,
            'success' => true,
        ];
    }
}

// Para colecciones paginadas
class ApiCollection extends ResourceCollection
{
    public function toArray($request): array
    {
        return [
            'data'       => $this->collection,
            'pagination' => [
                'total'        => $this->total(),
                'per_page'     => $this->perPage(),
                'current_page' => $this->currentPage(),
                'last_page'    => $this->lastPage(),
            ],
        ];
    }
}

Los errores necesitan el mismo tratamiento. Un handler global en app/Exceptions/Handler.php que serialice todas las excepciones al mismo formato elimina la inconsistencia de raíz.

app/Exceptions/Handler.php
public function render($request, Throwable $e)
{
    if ($request->expectsJson()) {
        $status = $this->resolveStatusCode($e);
        return response()->json([
            'success' => false,
            'message' => $e->getMessage(),
            'errors'  => $this->resolveErrors($e),
        ], $status);
    }
    return parent::render($request, $e);
}

Form Requests para toda la validación

La validación inline en los controllers es el origen de la mayoría de los controllers gordos. Un controller que valida, ejecuta lógica de negocio y formatea la respuesta en el mismo método es difícil de testear, difícil de modificar y muy fácil de romper accidentalmente.

Los Form Requests de Laravel sacan la validación del controller por completo. Más importante: centralizan las reglas de validación en un lugar donde son fáciles de encontrar, reusar y testear de forma independiente.

app/Http/Requests/StoreExpedienteRequest.php
class StoreExpedienteRequest extends FormRequest
{
    public function authorize(): bool
    {
        // La autorización también vive acá, no en el controller
        return $this->user()->can('crear-expedientes');
    }

    public function rules(): array
    {
        return [
            'numero'      => ['required', 'string', 'unique:expedientes'],
            'caratula'    => ['required', 'string', 'max:500'],
            'juzgado_id'  => ['required', 'exists:juzgados,id'],
            'cliente_ids' => ['required', 'array', 'min:1'],
            'cliente_ids.*' => ['exists:clientes,id'],
        ];
    }
}

El controller resultante queda limpio y enfocado en lo que le corresponde: orquestar la respuesta.

app/Http/Controllers/ExpedienteController.php
public function store(StoreExpedienteRequest $request): JsonResponse
{
    $expediente = CrearExpediente::run($request->validated());
    return new ApiResource($expediente);
}

Versionado desde el principio

La pregunta sobre si versionar la API suele aparecer cuando ya es tarde: cuando hay clientes usando /api/clientes y necesitás cambiar el formato de respuesta de ese endpoint de forma incompatible. En ese momento, el versionado se convierte en un proyecto de migración doloroso.

Agregar el prefijo de versión desde el inicio tiene costo cero y elimina ese problema para siempre. La estructura de rutas correcta:

routes/api.php
// v1 — estable, no romper compatibilidad
Route::prefix('v1')->group(base_path('routes/api/v1.php'));

// v2 — nueva versión con breaking changes
Route::prefix('v2')->group(base_path('routes/api/v2.php'));

Cada versión tiene su propio archivo de rutas, sus propios controllers y sus propios Resources. Comparten los modelos y la lógica de negocio. Cuando una versión deja de tener clientes activos, se elimina sin impacto.


Autenticación con Sanctum y tokens con scope

Para APIs consumidas por SPAs o aplicaciones móviles propias, Laravel Sanctum es la solución correcta. Para APIs consumidas por terceros, donde los clientes necesitan tokens con permisos granulares, Sanctum también lo soporta con abilities.

Crear token con permisos específicos
// El cliente puede leer expedientes pero no crearlos
$token = $user->createToken(
    'integracion-sistema-externo',
    ['expedientes:read', 'clientes:read']
)->plainTextToken();

// En el controller, verificar la ability
if (! $request->user()->tokenCan('expedientes:read')) {
    abort(403, 'Token sin permisos para esta operación');
}

Una API sin versionado es una API que en algún momento va a romper a sus clientes. El versionado no es una complejidad adicional: es la única forma de evolucionar sin destruir integraciones existentes.


Paginación consistente y filtros declarativos

Dos patrones que generan deuda técnica rápidamente si se implementan ad-hoc en cada controller: la paginación y los filtros. La paginación sin consistencia produce endpoints donde algunos devuelven 15 registros, otros 50 y otros todos. Los filtros implementados con condicionales inline en el controller se vuelven imposibles de mantener cuando hay más de tres criterios de búsqueda.

Para los filtros, el patrón de Query Scopes combinado con un objeto de filtro dedicado mantiene el controller limpio y los criterios de filtrado testeables de forma independiente:

app/Filters/ExpedienteFilter.php
class ExpedienteFilter
{
    public function apply(Builder $query, array $filters): Builder
    {
        return $query
            ->when($filters['estado'] ?? null, fn($q, $v) =>
                $q->where('estado', $v))
            ->when($filters['juzgado_id'] ?? null, fn($q, $v) =>
                $q->where('juzgado_id', $v))
            ->when($filters['buscar'] ?? null, fn($q, $v) =>
                $q->where('caratula', 'like', "%{$v}%"));
    }
}

// En el controller, queda así de limpio:
public function index(Request $request, ExpedienteFilter $filter)
{
    $expedientes = $filter
        ->apply(Expediente::query(), $request->all())
        ->paginate(20);

    return new ExpedienteCollection($expedientes);
}

Rate limiting por cliente, no por IP

El rate limiting por IP es el estándar por defecto de Laravel y funciona para proteger endpoints públicos. Para APIs autenticadas, el rate limiting correcto es por cliente o por token, no por IP. Un cliente con múltiples servidores o con NAT compartido comparte el límite de IP con otros clientes, lo que genera falsos positivos.

app/Providers/RouteServiceProvider.php
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)
        ->by($request->user()?->id ?: $request->ip());
});

Lo que estas decisiones tienen en común

Consistencia en respuestas, validación centralizada, versionado, filtros declarativos — todas estas decisiones comparten una característica: son más fáciles de implementar al principio que de incorporar después. Una API con tres endpoints puede refactorizarse en una tarde. Una API con trescientos endpoints usada en producción por clientes reales es otro problema completamente.

El mejor momento para tomar estas decisiones es antes de escribir el primer endpoint. El segundo mejor momento es ahora, antes de que la API crezca más.

Una API bien diseñada es la que permite agregar el endpoint número cien con el mismo esfuerzo que el primero. Si cada nuevo endpoint es más difícil que el anterior, el diseño está acumulando deuda.