BaseIndexRequest — Validación de parámetros de Index¶
El BaseIndexRequest
es una clase abstracta que proporciona validación y normalización consistente para todos los endpoints de listado/índice en la aplicación. Se integra perfectamente con el sistema BaseRepository
/BaseService
a través del DTO ListQuery
.
Características principales¶
- Validación completa: parámetros de búsqueda, paginación, ordenamiento y filtros
- Normalización automática: tipos de datos, booleanos, rangos y valores por defecto
- Extensibilidad: hooks personalizables por módulo
- Integración: directa con
ListQuery
,BaseRepository
yBaseService
- Frontend-ready: optimizado para Inertia.js y TanStack Table v8
Campos soportados¶
Campo | Tipo | Descripción | Ejemplo |
---|---|---|---|
q |
string\|null |
Término de búsqueda global | ?q=john |
page |
int |
Página actual (≥1) | ?page=2 |
perPage |
int |
Elementos por página | ?perPage=25 |
sort |
string\|null |
Campo de ordenamiento | ?sort=created_at |
dir |
string\|null |
Dirección (asc |desc ) |
?dir=desc |
filters |
array |
Filtros anidados | ?filters[status]=active |
Implementación básica¶
1. Crear FormRequest específico¶
<?php
namespace App\Http\Requests;
class UserIndexRequest extends BaseIndexRequest
{
protected function allowedSorts(): array
{
return [
'id',
'name',
'email',
'created_at',
'updated_at'
];
}
protected function filterRules(): array
{
return [
'filters.status' => ['nullable', 'string', 'in:active,inactive'],
'filters.role' => ['nullable', 'string', 'in:admin,user'],
'filters.created_between' => ['nullable', 'array'],
'filters.created_between.from' => ['nullable', 'date'],
'filters.created_between.to' => ['nullable', 'date'],
'filters.is_verified' => ['nullable', 'boolean'],
];
}
protected function maxPerPage(): int
{
return 50; // Límite específico para usuarios
}
}
2. Usar en Controller¶
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UserIndexRequest;
use App\Services\UserService;
class UserController extends Controller
{
public function __construct(
private UserService $userService
) {}
public function index(UserIndexRequest $request)
{
// Conversión automática a ListQuery
$listQuery = $request->toListQuery();
// Usar con el service
$result = $this->userService->list($listQuery);
// Respuesta optimizada para Inertia
return inertia('Users/Index', [
'users' => $result['rows'],
'meta' => $result['meta'],
'filters' => $request->input('filters', []),
]);
}
}
3. Frontend (React + TanStack Table v8)¶
import { useQuery } from '@tanstack/react-query';
import { router } from '@inertiajs/react';
export function UsersIndex({ users: initialUsers, meta: initialMeta, filters }) {
const [params, setParams] = useState({
q: '',
page: 1,
perPage: 15,
sort: 'created_at',
dir: 'desc',
filters: filters || {},
});
// TanStack Query con Inertia partial reloads
const { data } = useQuery({
queryKey: ['users', params],
queryFn: () => {
return router.visit(route('users.index'), {
method: 'get',
data: params,
only: ['users', 'meta'], // Partial reload
preserveState: true,
});
},
initialData: { users: initialUsers, meta: initialMeta },
});
// Integración con TanStack Table
const table = useReactTable({
data: data.users.data,
columns,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: data.meta.pageCount,
state: {
pagination: {
pageIndex: params.page - 1,
pageSize: params.perPage,
},
sorting: params.sort
? [
{
id: params.sort,
desc: params.dir === 'desc',
},
]
: [],
},
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function' ? updater({ pageIndex: params.page - 1, pageSize: params.perPage }) : updater;
setParams((prev) => ({
...prev,
page: newPagination.pageIndex + 1,
perPage: newPagination.pageSize,
}));
},
});
return <DataTable table={table} />;
}
Tipos de filtros soportados¶
Filtros simples¶
// URL: ?filters[status]=active
'filters.status' => ['nullable', 'string', 'in:active,inactive']
Filtros de rango (between)¶
// URL: ?filters[created_between][from]=2024-01-01&filters[created_between][to]=2024-12-31
'filters.created_between' => ['nullable', 'array'],
'filters.created_between.from' => ['nullable', 'date'],
'filters.created_between.to' => ['nullable', 'date'],
Filtros de array (IN)¶
// URL: ?filters[ids][]=1&filters[ids][]=2&filters[ids][]=3
'filters.ids' => ['nullable', 'array'],
'filters.ids.*' => ['integer', 'exists:users,id'],
Filtros booleanos¶
// URL: ?filters[is_verified]=true
'filters.is_verified' => ['nullable', 'boolean'],
Filtros de texto con like¶
// URL: ?filters[name_like]=john
'filters.name_like' => ['nullable', 'string', 'max:100'],
Filtros nulos¶
// URL: ?filters[deleted_at]=null
'filters.deleted_at' => ['nullable', 'string', 'in:null,notnull'],
Normalización automática¶
Booleanos¶
// Input: ?filters[is_active]=true
// Normalizado: ['is_active' => true]
// Input: ?filters[is_active]=false
// Normalizado: ['is_active' => false]
// Input: ?filters[is_active]=1
// Normalizado: ['is_active' => true]
Rangos between¶
// Input: ?filters[age_between][from]=50&filters[age_between][to]=18
// Normalizado: ['age_between' => ['from' => '18', 'to' => '50']] // Intercambiado automáticamente
Direcciones de ordenamiento¶
// Input: ?dir=ASC
// Normalizado: 'asc'
// Input: ?dir=DESC
// Normalizado: 'desc'
Hooks de extensibilidad¶
Reglas de filtro personalizadas¶
protected function filterRules(): array
{
return [
'filters.department_id' => ['nullable', 'integer', 'exists:departments,id'],
'filters.salary_range' => ['nullable', 'array'],
'filters.salary_range.min' => ['nullable', 'numeric', 'min:0'],
'filters.salary_range.max' => ['nullable', 'numeric', 'min:0'],
'filters.skills' => ['nullable', 'array'],
'filters.skills.*' => ['string', 'in:php,javascript,python'],
];
}
Límites personalizados¶
protected function maxPerPage(): int
{
// Límite más estricto para reportes complejos
return 25;
}
protected function defaultPerPage(): int
{
// Paginación más pequeña por defecto
return 10;
}
Sanitización personalizada¶
protected function sanitize(array $validated): array
{
// Limpiar espacios en búsqueda
if (isset($validated['q'])) {
$validated['q'] = trim($validated['q']);
}
// Convertir emails a lowercase en filtros
if (isset($validated['filters']['email'])) {
$validated['filters']['email'] = strtolower($validated['filters']['email']);
}
return $validated;
}
Integración con Inertia.js¶
Partial Reloads optimizados¶
// Controller
public function index(UserIndexRequest $request)
{
$result = $this->userService->list($request->toListQuery());
return inertia('Users/Index', [
'users' => $result['rows'],
'meta' => $result['meta'],
'filters' => $request->input('filters', []),
]);
}
// Frontend - Solo actualizar datos necesarios
const updateFilters = (newFilters) => {
router.visit(route('users.index'), {
method: 'get',
data: { ...params, filters: newFilters, page: 1 }, // Reset página
only: ['users', 'meta'], // Solo actualizar datos, no filtros UI
preserveState: true,
preserveScroll: true,
});
};
Patrones de URL¶
Búsqueda simple¶
GET /users?q=john&page=2&perPage=25&sort=name&dir=asc
Filtros complejos¶
GET /users?filters[status]=active&filters[role]=admin&filters[is_verified]=true
Rangos de fechas¶
GET /users?filters[created_between][from]=2024-01-01&filters[created_between][to]=2024-12-31
Filtros múltiples¶
GET /users?filters[ids][]=1&filters[ids][]=2&filters[skills][]=php&filters[skills][]=javascript
Best Practices¶
1. Validación defensiva¶
protected function filterRules(): array
{
return [
// Siempre usar 'nullable' para filtros
'filters.status' => ['nullable', 'string', 'in:active,inactive'],
// Validar existencia para foreign keys
'filters.department_id' => ['nullable', 'integer', 'exists:departments,id'],
// Limitar longitud de strings
'filters.search' => ['nullable', 'string', 'max:255'],
// Validar arrays con wildcard
'filters.tags' => ['nullable', 'array'],
'filters.tags.*' => ['string', 'max:50'],
];
}
2. Campos ordenables seguros¶
protected function allowedSorts(): array
{
return [
'id',
'name',
'email',
'created_at',
'updated_at',
// NO incluir campos sensibles como 'password', 'token', etc.
];
}
3. Límites razonables¶
protected function maxPerPage(): int
{
// Considerar el impacto en performance
return match($this->getResourceType()) {
'users' => 100,
'orders' => 50,
'reports' => 25,
default => 50,
};
}
4. Testing exhaustivo¶
/** @test */
public function validates_complex_filters()
{
$request = UserIndexRequest::createFromBase(request());
$data = [
'q' => 'search term',
'filters' => [
'status' => 'active',
'created_between' => [
'from' => '2024-01-01',
'to' => '2024-12-31'
],
'is_verified' => 'true',
'roles' => ['admin', 'user']
]
];
$request->replace($data);
$request->validateResolved();
$listQuery = $request->toListQuery();
$this->assertEquals('search term', $listQuery->q);
$this->assertTrue($listQuery->filters['is_verified']);
$this->assertIsArray($listQuery->filters['roles']);
}
Troubleshooting¶
Error: "Method allowedSorts() not found"¶
// ❌ Incorrecto: usar BaseIndexRequest directamente
class UserController
{
public function index(BaseIndexRequest $request) // Error!
}
// ✅ Correcto: crear clase concreta
class UserIndexRequest extends BaseIndexRequest
{
protected function allowedSorts(): array
{
return ['id', 'name', 'email'];
}
}
Error: Validación falla con filtros booleanos¶
// ❌ Problema: string 'true' no valida como boolean
'filters.is_active' => 'true' // Falla validación
// ✅ Solución: BaseIndexRequest normaliza automáticamente
// 'true', 'false', '1', '0' → boolean real en prepareForValidation()
Error: Rangos between incorrectos¶
// ❌ Problema: from > to causa resultados vacíos
?filters[date_range][from]=2024-12-31&filters[date_range][to]=2024-01-01
// ✅ Solución: BaseIndexRequest intercambia automáticamente
// Resultado: from=2024-01-01, to=2024-12-31
Performance: perPage muy alto¶
// ❌ Problema: usuario solicita perPage=1000
?perPage=1000
// ✅ Solución: maxPerPage() limita automáticamente
protected function maxPerPage(): int
{
return 100; // Máximo permitido
}
Evolución y extensiones futuras¶
Filtros avanzados¶
- Soporte para operadores personalizados (
>=
,<=
,!=
) - Filtros por distancia geográfica
- Filtros de texto con fuzzy matching
Performance¶
- Cache de queries complejas
- Índices automáticos basados en filtros frecuentes
- Paginación cursor-based para datasets grandes
Analytics¶
- Tracking de filtros más usados
- Métricas de performance por endpoint
- Sugerencias de optimización automáticas