Curso de Laravel
CRUD en Laravel: listar registros con paginación y búsqueda
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Empezamos el módulo del CRUD, donde juntamos todo lo visto hasta ahora en una aplicación que funciona.
Hoy la primera letra: la L de read, la pantalla de listado.
¡Empecemos!
Preparar el terreno
Ya tenemos el modelo, la migración y los datos de prueba. Falta conectar ruta, controlador y vista:
// routes/web.php
use App\Http\Controllers\PacienteController;
Route::resource('pacientes', PacienteController::class);
php artisan make:controller PacienteController --resource --model=Paciente
El método index
public function index()
{
$pacientes = Paciente::orderBy('apellido')->paginate(15);
return view('pacientes.index', compact('pacientes'));
}
Tres líneas, y ya tienes el listado paginado.
Fíjate en paginate(15) en lugar de get(). La diferencia importa: con 5.000 pacientes, get() los trae todos a memoria para mostrar quince.
La vista
@extends('layouts.app')
@section('titulo', 'Pacientes')
@section('contenido')
<div class="cabecera">
<h1>Pacientes</h1>
<a href="{{ route('pacientes.create') }}" class="boton">Nuevo paciente</a>
</div>
<table>
<thead>
<tr>
<th>Cédula</th>
<th>Nombre</th>
<th>Teléfono</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@forelse ($pacientes as $paciente)
<tr>
<td>{{ $paciente->cedula }}</td>
<td>{{ $paciente->nombre }} {{ $paciente->apellido }}</td>
<td>{{ $paciente->telefono ?? '—' }}</td>
<td>
<a href="{{ route('pacientes.show', $paciente) }}">Ver</a>
<a href="{{ route('pacientes.edit', $paciente) }}">Editar</a>
</td>
</tr>
@empty
<tr>
<td colspan="4">Todavía no hay pacientes registrados.</td>
</tr>
@endforelse
</tbody>
</table>
{{ $pacientes->links() }}
@endsection
El @forelse resuelve el caso vacío sin un @if alrededor, y {{ $pacientes->links() }} genera los enlaces de paginación completos.
Ese caso vacío importa más de lo que parece. Una tabla sin filas y sin explicación deja al usuario sin saber si el sistema falló o simplemente no hay datos.
Añadir un buscador
Aquí es donde se aplica el when() que vimos en la lección de consultas:
public function index(Request $request)
{
$pacientes = Paciente::query()
->when($request->buscar, function ($consulta, $termino) {
$consulta->where(function ($q) use ($termino) {
$q->where('nombre', 'like', "%{$termino}%")
->orWhere('apellido', 'like', "%{$termino}%")
->orWhere('cedula', 'like', "{$termino}%");
});
})
->orderBy('apellido')
->paginate(15)
->withQueryString();
return view('pacientes.index', compact('pacientes'));
}
Tres detalles importantes:
La función anónima interna agrupa las tres condiciones. Sin ella, el orWhere se saldría del filtro y devolvería resultados incorrectos, como advertimos al hablar de precedencia.
withQueryString() conserva el término de búsqueda al pasar de página. Sin él, la página 2 muestra todos los pacientes.
La cédula busca por prefijo, "{$termino}%", no con comodín inicial. Como vimos en el curso de MySQL, un LIKE '%algo%' no puede usar el índice.
Y el formulario:
<form method="GET" action="{{ route('pacientes.index') }}">
<input type="search" name="buscar" value="{{ request('buscar') }}"
placeholder="Buscar por nombre o cédula">
<button type="submit">Buscar</button>
@if (request('buscar'))
<a href="{{ route('pacientes.index') }}">Limpiar</a>
@endif
</form>
Método GET, no POST. Como vimos en el curso de PHP: GET para consultar, POST para modificar. Así el usuario puede compartir el enlace del resultado y usar el botón de atrás.
Filtros combinados
El mismo patrón escala a varios filtros:
$citas = Cita::query()
->with(['paciente', 'doctor'])
->when($request->estado, fn ($q, $v) => $q->where('estado', $v))
->when($request->doctor_id, fn ($q, $v) => $q->where('doctor_id', $v))
->when($request->desde, fn ($q, $v) => $q->whereDate('fecha', '>=', $v))
->when($request->hasta, fn ($q, $v) => $q->whereDate('fecha', '<=', $v))
->orderByDesc('fecha')
->paginate(20)
->withQueryString();
Cada filtro se aplica solo si el usuario lo llenó.
Carga ansiosa: el detalle que decide el rendimiento
Fíjate en el with(['paciente', 'doctor']) del ejemplo anterior. Es lo más importante de esta lección.
Sin él, esta vista:
@foreach ($citas as $cita)
<td>{{ $cita->paciente->nombre }}</td>
<td>{{ $cita->doctor->nombre }}</td>
@endforeach
lanza dos consultas por cada fila. Con 20 citas por página, son 41 consultas en lugar de 3.
Es el problema N+1, y en las pantallas de listado es donde más aparece, porque el bucle está en la vista y no se ve desde el controlador.
La regla: si la vista accede a una relación, cárgala con with() en el controlador.
Ordenar por columnas
Una mejora que se agradece y cuesta poco:
public function index(Request $request)
{
$columnasPermitidas = ['apellido', 'cedula', 'created_at'];
$orden = in_array($request->orden, $columnasPermitidas)
? $request->orden
: 'apellido';
$direccion = $request->direccion === 'desc' ? 'desc' : 'asc';
$pacientes = Paciente::orderBy($orden, $direccion)
->paginate(15)
->withQueryString();
return view('pacientes.index', compact('pacientes'));
}
La lista blanca no es opcional. Pasar directamente $request->orden a orderBy() permite que alguien ordene por cualquier columna, incluidas las que no debería ver. Es una vulnerabilidad real.
Y en la cabecera de la tabla:
<th>
<a href="{{ route('pacientes.index', array_merge(request()->query(), [
'orden' => 'apellido',
'direccion' => request('direccion') === 'asc' ? 'desc' : 'asc',
])) }}">
Nombre
@if (request('orden') === 'apellido')
{{ request('direccion') === 'asc' ? '↑' : '↓' }}
@endif
</a>
</th>
Ese array_merge(request()->query(), ...) conserva los filtros de búsqueda al cambiar el orden.
Contar sin traer los registros
Para mostrar totales junto a cada fila:
$doctores = Doctor::withCount([
'citas',
'citas as atendidas_count' => fn ($q) => $q->where('estado', 'atendida'),
])->paginate(15);
<td>{{ $doctor->citas_count }} citas ({{ $doctor->atendidas_count }} atendidas)</td>
Es lo que vimos en la lección de relaciones: cuenta en la base de datos, sin traer una sola cita.
Mostrar el total de resultados
<p>
Mostrando {{ $pacientes->firstItem() }} a {{ $pacientes->lastItem() }}
de {{ $pacientes->total() }} pacientes
</p>
Otros métodos del paginador:
$pacientes->currentPage();
$pacientes->lastPage();
$pacientes->hasPages();
$pacientes->onFirstPage();
Personalizar la paginación
Las vistas por defecto usan Tailwind. Para editarlas:
php artisan vendor:publish --tag=laravel-pagination
Se copian a resources/views/vendor/pagination/, donde puedes adaptarlas a tu diseño.
Y si trabajas con Bootstrap, en AppServiceProvider:
Paginator::useBootstrapFive();
Errores comunes
- No usar
with()y provocar un N+1 desde la vista. get()en lugar depaginate()en tablas que crecen.- Olvidar
withQueryString()y perder los filtros al paginar. orderBy($request->orden)sin lista blanca.- No manejar el caso vacío, dejando una tabla en blanco sin explicación.
- Buscador con
%termino%en tablas grandes sin considerar el costo.
Para cerrar
La pantalla de listado es la que más se usa de cualquier sistema, y también donde más se degrada el rendimiento si no se cuida.
Las tres cosas: paginate() siempre, with() para las relaciones que muestre la vista, y withQueryString() para no perder los filtros.
En la siguiente lección veremos el formulario de creación.
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.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp