Laravel y REST API: sistema avanzado de filtrado y ordenación con PostgreSQL
Introducción: por qué las herramientas estándar de Laravel no son suficientes
Laravel ofrece un cómodo ORM Eloquent y paginación integrada, pero en cuanto el cliente empieza a exigir filtrado por diez campos, ordenación por valores calculados y paginación por cursor para scroll infinito, los controladores se convierten en un caos de if ($request->has(...)). Los equipos sin un enfoque sistemático acaban con: duplicación de lógica de filtrado en distintos controladores, inyecciones SQL a través de ordenación dinámica, consultas N+1 y una documentación inexistente para el equipo frontend. En 2026, cuando un REST API sirve simultáneamente a un cliente web, una aplicación móvil e integradores externos, esto es inaceptable.
En este artículo construiremos un sistema completo de filtrado en Laravel con PostgreSQL: desde el diseño del contrato API hasta la optimización de consultas y las pruebas.
Diseño del contrato API: parámetros de consulta y convenciones
Antes de escribir código, fija el contrato. Parámetros caóticos como filterByStatus, status_filter y status en distintos endpoints son el primer signo de ausencia de sistema.
Convenciones recomendadas
- Filtros:
filter[field]=valueofilter[field][operator]=value - Ordenación:
sort=fieldpara ASC,sort=-fieldpara DESC (prefijo menos como en JSON:API) - Paginación:
page[size]=25&page[cursor]=eyJpZCI6MTAwfQpara cursor opage[number]=2&page[size]=25para offset - Búsqueda de texto completo:
search=query
Ejemplos de URLs reales:
GET /api/v1/products?filter[status]=active&filter[price][gte]=100&sort=-created_at&page[size]=20
GET /api/v1/products?search=wireless+headphones&filter[category_id]=5&sort=price
GET /api/v1/orders?filter[created_at][gte]=2026-01-01&filter[user_id]=42&sort=-total
Este formato se autodocumenta, es fácil de parsear en el cliente y escala sin cambios en la arquitectura.
Patrón Filter/Scope en Laravel: clases de filtrado reutilizables
La idea clave es extraer la lógica de filtrado del controlador a una clase independiente, aplicada mediante un local scope de Eloquent. El controlador permanece ligero y la lógica se prueba de forma aislada.
Clase base del filtro
<?php
namespace App\Http\Filters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class AbstractFilter
{
protected Request $request;
protected Builder $builder;
// Mapa: nombre del parámetro => método manejador
protected array $filters = [];
public function __construct(Request $request)
{
$this->request = $request;
}
public function apply(Builder $builder): Builder
{
$this->builder = $builder;
foreach ($this->filters as $param => $method) {
$value = data_get($this->request->input('filter'), $param);
if ($value !== null && $value !== '') {
$this->$method($value);
}
}
return $this->builder;
}
}
Filtro concreto para el modelo Product
<?php
namespace App\Http\Filters;
use Illuminate\Database\Eloquent\Builder;
class ProductFilter extends AbstractFilter
{
protected array $filters = [
'status' => 'byStatus',
'category_id' => 'byCategory',
'price' => 'byPrice',
'created_at' => 'byCreatedAt',
];
protected function byStatus(string $value): void
{
$this->builder->where('status', $value);
}
protected function byCategory(int|string $value): void
{
$this->builder->where('category_id', (int) $value);
}
// Soporte de operadores: filter[price][gte]=100&filter[price][lte]=500
protected function byPrice(array|string $value): void
{
if (is_array($value)) {
$operators = ['gte' => '>=', 'lte' => '<=', 'gt' => '>', 'lt' => '<'];
foreach ($operators as $key => $op) {
if (isset($value[$key])) {
$this->builder->where('price', $op, (float) $value[$key]);
}
}
} else {
$this->builder->where('price', (float) $value);
}
}
protected function byCreatedAt(array|string $value): void
{
if (is_array($value)) {
if (isset($value['gte'])) {
$this->builder->whereDate('created_at', '>=', $value['gte']);
}
if (isset($value['lte'])) {
$this->builder->whereDate('created_at', '<=', $value['lte']);
}
}
}
}
Trait para el modelo y el scope
<?php
namespace App\Traits;
use App\Http\Filters\AbstractFilter;
use Illuminate\Database\Eloquent\Builder;
trait Filterable
{
public function scopeFilter(Builder $query, AbstractFilter $filter): Builder
{
return $filter->apply($query);
}
}
// En el modelo Product:
// use Filterable;
// En el controlador:
// Product::filter($filter)->paginate();
El controlador queda conciso:
<?php
public function index(Request $request, ProductFilter $filter)
{
$products = Product::filter($filter)
->applySorting($request)
->cursorPaginate($request->input('page.size', 25));
return ProductResource::collection($products);
}
Ordenación dinámica: la seguridad es lo primero
Pasar directamente $request->input('sort') a orderBy() es una inyección SQL. El enfoque de lista blanca (whitelist) es obligatorio.
<?php
namespace App\Http\Sorts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class ProductSorter
{
// Campos permitidos y sus alias en la BD
protected array $allowedSorts = [
'price' => 'price',
'created_at' => 'created_at',
'name' => 'name',
'rating' => 'average_rating', // alias para campo calculado
];
public function apply(Builder $query, Request $request): Builder
{
$sortParam = $request->input('sort', '-created_at');
$direction = str_starts_with($sortParam, '-') ? 'desc' : 'asc';
$field = ltrim($sortParam, '-');
if (!array_key_exists($field, $this->allowedSorts)) {
// Ignoramos la ordenación inválida y aplicamos la predeterminada
return $query->orderBy('created_at', 'desc');
}
return $query->orderBy($this->allowedSorts[$field], $direction)
->orderBy('id', $direction); // desempate para ordenación estable
}
}
El desempate por id es fundamental para una paginación por cursor correcta: sin él, el orden de los registros con el mismo valor del campo de ordenación es impredecible.
Paginación por cursor vs paginación por offset
La paginación por offset (LIMIT 25 OFFSET 500) es simple, pero con grandes volúmenes PostgreSQL debe contar y descartar las primeras 500 filas. Con millones de registros esto degenera en un full scan.
La paginación por cursor utiliza una condición WHERE sobre el valor del último registro: WHERE (created_at, id) < ('2026-01-15', 1000). PostgreSQL usa el índice directamente — O(log n) en lugar de O(n).
Laravel 8+ tiene cursorPaginate() integrado, pero solo admite ordenación por un campo. Para ordenación compuesta lo implementamos manualmente:
<?php
namespace App\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class CursorPaginator
{
public function paginate(Builder $query, int $perPage, ?string $cursor): array
{
if ($cursor) {
$decoded = json_decode(base64_decode($cursor), true);
// Condición compuesta: (sort_field, id) < (value, id)
$query->where(function ($q) use ($decoded) {
$q->where('created_at', '<', $decoded['created_at'])
->orWhere(function ($q2) use ($decoded) {
$q2->where('created_at', $decoded['created_at'])
->where('id', '<', $decoded['id']);
});
});
}
$items = $query->limit($perPage + 1)->get();
$hasMore = $items->count() > $perPage;
$items = $items->take($perPage);
$nextCursor = null;
if ($hasMore && $last = $items->last()) {
$nextCursor = base64_encode(json_encode([
'created_at' => $last->created_at->toISOString(),
'id' => $last->id,
]));
}
return ['data' => $items, 'next_cursor' => $nextCursor, 'has_more' => $hasMore];
}
}
Cuándo usar offset: paneles de administración donde se necesita saltar a una página concreta. Cursor: para scroll infinito y APIs públicas de alta carga.
Aprovechando las capacidades de PostgreSQL
Búsqueda de texto completo con tsvector
En lugar de LIKE '%query%' (seq scan), utiliza la búsqueda de texto completo integrada de PostgreSQL:
-- Migración: añadimos la columna y el índice
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('spanish', coalesce(name, '') || ' ' || coalesce(description, ''))
) STORED;
CREATE INDEX products_search_vector_idx ON products USING GIN(search_vector);
<?php
// En el filtro o en el scope:
protected function search(string $query): void
{
$this->builder->whereRaw(
"search_vector @@ plainto_tsquery('spanish', ?)",
[$query]
)->orderByRaw(
"ts_rank(search_vector, plainto_tsquery('spanish', ?)) DESC",
[$query]
);
}
Filtrado por campos JSONB
<?php
// Filtrado por JSON anidado: filter[attributes][color]=red
protected function byAttributes(array $value): void
{
foreach ($value as $key => $val) {
$this->builder->whereRaw(
"attributes->>? = ?",
[$key, $val]
);
}
}
// Índice para JSONB:
// CREATE INDEX products_attributes_gin ON products USING GIN(attributes);
Optimización de consultas: EXPLAIN ANALYZE e índices
Cada filtro popular debe estar cubierto por un índice. Analiza los planes de consulta:
-- Ejecuta en psql o mediante Laravel DB::select()
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM products
WHERE status = 'active'
AND category_id = 5
AND price BETWEEN 100 AND 500
ORDER BY created_at DESC, id DESC
LIMIT 25;
Índice compuesto para una combinación típica de filtros:
-- Orden de columnas: primero filtros de igualdad, luego rango, luego ordenación
CREATE INDEX products_listing_idx
ON products (status, category_id, price, created_at DESC, id DESC)
WHERE status = 'active'; -- el índice parcial ahorra espacio
En Laravel, selecciona solo los campos necesarios:
<?php
Product::filter($filter)
->select(['id', 'name', 'price', 'status', 'created_at', 'thumbnail_url'])
->with(['category:id,name']) // eager loading solo de los campos necesarios
->cursorPaginate(25);
Caché de resultados de filtrado
Cachear una API con parámetros variados es una tarea no trivial. No lo cachees todo: para combinaciones únicas de filtros la caché es inútil y contaminará Redis.
Estrategia: caché solo para solicitudes "calientes"
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\Request;
class FilterCacheService
{
// Solo cacheamos si la solicitud no tiene filtros de usuario
// (páginas de catálogo, portada) — TTL 5 minutos
public function remember(Request $request, callable $callback): mixed
{
$filterParams = $request->input('filter', []);
$hasUserSpecificFilter = isset($filterParams['user_id']);
if ($hasUserSpecificFilter || count($filterParams) > 3) {
return $callback(); // sin caché
}
$cacheKey = 'products:' . md5($request->getQueryString());
return Cache::tags(['products'])->remember($cacheKey, 300, $callback);
}
// Invalidación al cambiar datos:
public static function flush(): void
{
Cache::tags(['products'])->flush();
}
}
Para Redis, usa caché etiquetada (Cache::tags()): esto permite invalidar todos los registros con la etiqueta products ante cualquier cambio en el catálogo mediante un Observer.
Documentación del API de filtrado: OpenAPI/Swagger
Sin documentación, el equipo frontend tendrá que adivinar los parámetros. Usa el paquete darkaonline/l5-swagger con anotaciones PHP:
<?php
/**
* @OA\Get(
* path="/api/v1/products",
* summary="Lista de productos con filtrado",
* tags={"Products"},
* @OA\Parameter(
* name="filter[status]",
* in="query",
* description="Estado del producto: active, inactive, draft",
* @OA\Schema(type="string", enum={"active", "inactive", "draft"})
* ),
* @OA\Parameter(
* name="filter[price][gte]",
* in="query",
* description="Precio mínimo",
* @OA\Schema(type="number")
* ),
* @OA\Parameter(
* name="sort",
* in="query",
* description="Campo de ordenación. Prefijo '-' para DESC. Disponible: price, created_at, name, rating",
* @OA\Schema(type="string", example="-created_at")
* ),
* @OA\Parameter(
* name="page[cursor]",
* in="query",
* description="Cursor para la siguiente página (del campo next_cursor de la respuesta anterior)",
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="OK")
* )
*/
public function index(Request $request, ProductFilter $filter): JsonResponse
{
// ...
}
Pruebas: unit tests y feature tests
Prueba cada filtro de forma aislada (unit) y el comportamiento completo del endpoint (feature).
<?php
namespace Tests\Unit\Filters;
use App\Http\Filters\ProductFilter;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
class ProductFilterTest extends TestCase
{
use RefreshDatabase;
public function test_filters_by_status(): void
{
Product::factory()->count(3)->create(['status' => 'active']);
Product::factory()->count(2)->create(['status' => 'inactive']);
$request = Request::create('/', 'GET', ['filter' => ['status' => 'active']]);
$filter = new ProductFilter($request);
$result = Product::filter($filter)->get();
$this->assertCount(3, $result);
$this->assertTrue($result->every(fn($p) => $p->status === 'active'));
}
public function test_filters_by_price_range(): void
{
Product::factory()->create(['price' => 50]);
Product::factory()->create(['price' => 200]);
Product::factory()->create(['price' => 600]);
$request = Request::create('/', 'GET', [
'filter' => ['price' => ['gte' => '100', 'lte' => '500']]
]);
$filter = new ProductFilter($request);
$result = Product::filter($filter)->get();
$this->assertCount(1, $result);
$this->assertEquals(200, $result->first()->price);
}
public function test_invalid_sort_field_falls_back_to_default(): void
{
$request = Request::create('/', 'GET', ['sort' => 'malicious_field; DROP TABLE products;--']);
// Verificamos que la consulta se ejecuta sin excepciones
$response = $this->getJson('/api/v1/products?sort=malicious_field');
$response->assertOk();
}
}
Conclusión: checklist de un API de filtrado de calidad
- ✅ Contrato API unificado, fijado y documentado en OpenAPI
- ✅ Lógica de filtrado extraída a clases Filter independientes, no en el controlador
- ✅ Lista blanca de campos de ordenación permitidos — protección contra inyecciones SQL
- ✅ Paginación por cursor para endpoints públicos con gran volumen de datos
- ✅ Búsqueda de texto completo con tsvector en lugar de LIKE
- ✅ Índices compuestos de PostgreSQL para combinaciones reales de filtros
- ✅ EXPLAIN ANALYZE verificado para las 5 consultas más frecuentes
- ✅ SELECT solo de los campos necesarios, eager loading sin N+1
- ✅ Caché etiquetada con invalidación por eventos del modelo
- ✅ Unit tests para cada filtro, feature tests para los endpoints
El enfoque sistemático del filtrado no es una optimización prematura. Es una decisión arquitectónica que determina si tu REST API en Laravel podrá soportar una carga creciente sin necesidad de refactorización en seis meses. PostgreSQL ofrece un arsenal potente: índices GIN, tsvector, JSONB — úsalos de forma consciente, basándote en los planes de consulta reales y no en la intuición.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →