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 de paginate() 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.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp