Si trabajas con Laravel en proyectos medianos o grandes, tarde o temprano te encontrarás con endpoints llenos de filtros: por estado, fecha, usuario, rango de precios, búsqueda por texto, etc. Y si no tienes cuidado, terminas con controladores gigantes, lógica duplicada y queries difíciles de mantener.
Hoy quiero enseñarte un enfoque que llevo años usando en producción y que funciona muy bien: clases de filtros que extienden de una clase base QueryFilter.
Te lo explico como si estuviéramos revisando código juntos.
El problema típico
Seguro has visto algo así:
public function index(Request $request)
{
$query = Order::query();
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->user_id) {
$query->where('user_id', $request->user_id);
}
if ($request->from_date) {
$query->whereDate('created_at', '>=', $request->from_date);
}
if ($request->to_date) {
$query->whereDate('created_at', '<=', $request->to_date);
}
return $query->paginate();
}
Funciona… pero:
- El controlador crece demasiado
- No es reutilizable
- No es testeable fácilmente
- Rompe SRP (Single Responsibility Principle)
- Si agregas más filtros, se vuelve inmanejable
Query Filters Pattern es la solución
La idea es separar cada filtro en su propia clase (o método) y aplicarlos dinámicamente según los parámetros recibidos.
Ventajas:
- Código limpio
- Reutilizable
- Escalable
- Testeable
- Muy expresivo
- Ideal para APIs
Vamos a crear:
- Una clase abstracta
QueryFilter - Una clase concreta por modelo (ej:
OrderFilters) - Métodos que representan filtros
- Aplicación automática según parámetros del request
Paso 1 — Crear la clase base QueryFilter
Esta clase se encargará de:
- Leer los parámetros del request
- Detectar qué filtros aplicar
- Ejecutarlos sobre el query builder
namespace App\Filters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class QueryFilter
{
protected Builder $builder;
protected Request $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function apply(Builder $builder): Builder
{
$this->builder = $builder;
foreach ($this->filters() as $name => $value) {
if (method_exists($this, $name)) {
$this->$name($value);
}
}
return $this->builder;
}
protected function filters(): array
{
return $this->request->all();
}
}
Lo importante aquí es:
- Itera todos los parámetros del request
- Si existe un método con ese nombre, lo ejecuta
- Cada método modifica el builder
Paso 2 — Crear filtros específicos del modelo
Ejemplo: OrderFilters
php artisan make:class Filters/OrderFiltersnamespace App\Filters;
class OrderFilters extends QueryFilter
{
public function status(string $status): void
{
$this->builder->where('status', $status);
}
public function user_id(int $userId): void
{
$this->builder->where('user_id', $userId);
}
public function from_date(string $date): void
{
$this->builder->whereDate('created_at', '>=', $date);
}
public function to_date(string $date): void
{
$this->builder->whereDate('created_at', '<=', $date);
}
}
Cada método representa un filtro posible.
Si el request contiene:
/api/orders?status=paid&user_id=5Se ejecutarán automáticamente:
status('paid')user_id(5)
Paso 3 — Integrarlo en el modelo (Scope)
Para hacerlo elegante, podemos usar un Local Scope.
// App\Models\Order.php
use App\Filters\OrderFilters;
public function scopeFilter($query, OrderFilters $filters)
{
return $filters->apply($query);
}
Paso 4 — Usarlo en el controlador
Ahora el controlador queda extremadamente limpio:
public function index(OrderFilters $filters)
{
$orders = Order::query()
->filter($filters)
->latest()
->paginate();
return OrderResource::collection($orders);
}
Ejemplo real de uso
Petición:
GET /api/orders?status=paid&from_date=2026-01-01
Query Resultante
SELECT * FROM orders
WHERE status = 'paid'
AND created_at >= '2026-01-01'
ORDER BY created_at DESC
Sin condiciones manuales ni código duplicado.
Paso 5 — Filtros más avanzados
Búsqueda por texto
public function search(string $term): void
{
$this->builder->where(function ($q) use ($term) {
$q->where('title', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%");
});
}
Rangos
/api/orders?min_total=100&max_total=500public function min_total($value): void
{
$this->builder->where('total', '>=', $value);
}
public function max_total($value): void
{
$this->builder->where('total', '<=', $value);
}
Filtros por relaciones
public function customer(string $name): void
{
$this->builder->whereHas('customer', function ($q) use ($name) {
$q->where('name', 'like', "%{$name}%");
});
}
Tips importantes de producción
Valida los parámetros antes
Usa FormRequest para evitar datos inválidos.
Evita filtrar todo indiscriminadamente
Puedes restringir qué parámetros son válidos:
protected array $allowedFilters = [
'status',
'user_id',
'from_date',
'to_date',
];
Y luego:
protected function filters(): array
{
return $this->request->only($this->allowedFilters);
}Hazlos reutilizables
Puedes tener filtros compartidos:
DateRangeFilterSortableFilterSearchFilter
¿Cuándo usar este patrón?
Úsalo cuando:
- Tu API tiene muchos filtros
- Quieres mantener controladores limpios
- El proyecto va a crecer
- Trabajas en equipo
- Quieres código mantenible
No es necesario para CRUD simples.
El patrón de Query Filters es una de esas cosas que, una vez lo adoptas, ya no quieres volver atrás.
Te permite:
- Escribir código elegante
- Separar responsabilidades
- Escalar sin dolor
- Mantener consistencia en toda la API
Si tu proyecto Laravel empieza a tener lógica de filtrado compleja, este enfoque te va a ahorrar muchísimo tiempo (y dolores de cabeza).

