Skip to content

Paginación y buscador ​

Cuando una tabla pasa de unas cuantas decenas de registros necesitas dos cosas: paginar (mostrar 12 por página) y buscar/filtrar. Este snippet hace ambas a la vez, construyendo la consulta de forma dinámica pero siempre con parámetros preparados.

Función de paginación reutilizable ​

php
<?php
require_once __DIR__ . '/db.php';

/**
 * Ejecuta una consulta paginada.
 *
 * @param string $select  Parte SELECT ... FROM ... (sin WHERE)
 * @param array  $where   Condiciones SQL, p. ej. ['activo = 1', 'precio <= :max']
 * @param array  $params  Parámetros con nombre para las condiciones
 * @param string $orden   Cláusula ORDER BY (¡sólo valores de una lista blanca!)
 * @param int    $porPagina
 * @return array ['items' => [...], 'total' => int, 'pagina' => int, 'paginas' => int]
 */
function paginar(string $select, array $where, array $params, string $orden, int $porPagina = 12): array
{
    $pagina = max(1, (int) ($_GET['pagina'] ?? 1));
    $sqlWhere = $where ? ' WHERE ' . implode(' AND ', $where) : '';

    // Total de resultados (para saber cuántas páginas hay)
    $sqlTotal = 'SELECT COUNT(*) FROM (' . $select . $sqlWhere . ') AS t';
    $stmt = db()->prepare($sqlTotal);
    $stmt->execute($params);
    $total = (int) $stmt->fetchColumn();

    $paginas = max(1, (int) ceil($total / $porPagina));
    $pagina = min($pagina, $paginas);
    $offset = ($pagina - 1) * $porPagina;

    // LIMIT y OFFSET como enteros ya validados
    $stmt = db()->prepare($select . $sqlWhere . " ORDER BY $orden LIMIT $porPagina OFFSET $offset");
    $stmt->execute($params);

    return [
        'items'   => $stmt->fetchAll(),
        'total'   => $total,
        'pagina'  => $pagina,
        'paginas' => $paginas,
    ];
}

/** Genera una URL conservando los filtros actuales y cambiando sólo algunos parámetros */
function url_con(array $cambios): string
{
    $query = array_merge($_GET, $cambios);
    $query = array_filter($query, fn($v) => $v !== '' && $v !== null);
    return '?' . http_build_query($query);
}

/** Pinta la navegación: « 1 … 4 5 [6] 7 8 … 20 » */
function enlaces_paginacion(int $pagina, int $paginas, int $alrededor = 2): string
{
    if ($paginas <= 1) return '';
    $html = '<nav class="paginacion" aria-label="Paginación">';
    $html .= $pagina > 1 ? '<a href="' . url_con(['pagina' => $pagina - 1]) . '">« Anterior</a>' : '';

    for ($i = 1; $i <= $paginas; $i++) {
        $esExtremo = $i === 1 || $i === $paginas;
        $cerca = abs($i - $pagina) <= $alrededor;
        if ($esExtremo || $cerca) {
            $html .= $i === $pagina
                ? '<span class="actual" aria-current="page">' . $i . '</span>'
                : '<a href="' . url_con(['pagina' => $i]) . '">' . $i . '</a>';
        } elseif (abs($i - $pagina) === $alrededor + 1) {
            $html .= '<span>…</span>';
        }
    }

    $html .= $pagina < $paginas ? '<a href="' . url_con(['pagina' => $pagina + 1]) . '">Siguiente »</a>' : '';
    return $html . '</nav>';
}

Catálogo con buscador y filtros ​

php
<?php
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/paginar.php';

// Filtros que llegan por GET (se pueden compartir como enlace)
$q      = trim($_GET['q'] ?? '');
$cat    = (int) ($_GET['cat'] ?? 0);
$min    = $_GET['min'] ?? '';
$max    = $_GET['max'] ?? '';
$ordenK = $_GET['orden'] ?? 'recientes';

$where  = ['p.activo = 1'];
$params = [];

if ($q !== '') {
    // Busca en nombre y descripción. Cada placeholder con nombre distinto
    $where[] = '(p.nombre LIKE :q1 OR p.descripcion LIKE :q2)';
    $params['q1'] = $params['q2'] = '%' . $q . '%';
}
if ($cat > 0) {
    $where[] = 'p.categoria_id = :cat';
    $params['cat'] = $cat;
}
if (is_numeric($min)) {
    $where[] = 'p.precio >= :min';
    $params['min'] = $min;
}
if (is_numeric($max)) {
    $where[] = 'p.precio <= :max';
    $params['max'] = $max;
}

// Lista blanca para ORDER BY (no se puede parametrizar)
$ordenes = [
    'recientes'   => 'p.id DESC',
    'precio_asc'  => 'p.precio ASC',
    'precio_desc' => 'p.precio DESC',
    'nombre'      => 'p.nombre ASC',
];
$orden = $ordenes[$ordenK] ?? $ordenes['recientes'];

$res = paginar(
    'SELECT p.id, p.nombre, p.precio, c.nombre AS categoria
     FROM productos p LEFT JOIN categorias c ON c.id = p.categoria_id',
    $where, $params, $orden, 12
);

$categorias = db()->query('SELECT id, nombre FROM categorias ORDER BY nombre')->fetchAll();
?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Catálogo</title>
  <style>
    .filtros { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
    .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
    .card { border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px; }
    .paginacion { display: flex; gap: 6px; margin-top: 24px; flex-wrap: wrap; }
    .paginacion a, .paginacion span { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; text-decoration: none; }
    .paginacion .actual { background: #4f46e5; color: #fff; border-color: #4f46e5; }
    mark { background: #fef08a; }
  </style>
</head>
<body>
  <form class="filtros" method="get">
    <input type="search" name="q" value="<?= e($q) ?>" placeholder="Buscar productos...">
    <select name="cat">
      <option value="">Todas las categorías</option>
      <?php foreach ($categorias as $c): ?>
        <option value="<?= $c['id'] ?>" <?= $cat === (int) $c['id'] ? 'selected' : '' ?>><?= e($c['nombre']) ?></option>
      <?php endforeach; ?>
    </select>
    <input type="number" name="min" value="<?= e($min) ?>" placeholder="Precio mín." step="0.01">
    <input type="number" name="max" value="<?= e($max) ?>" placeholder="Precio máx." step="0.01">
    <select name="orden">
      <option value="recientes">Más recientes</option>
      <option value="precio_asc" <?= $ordenK === 'precio_asc' ? 'selected' : '' ?>>Precio: menor a mayor</option>
      <option value="precio_desc" <?= $ordenK === 'precio_desc' ? 'selected' : '' ?>>Precio: mayor a menor</option>
      <option value="nombre" <?= $ordenK === 'nombre' ? 'selected' : '' ?>>Nombre A-Z</option>
    </select>
    <button>Filtrar</button>
    <a href="catalogo.php">Limpiar</a>
  </form>

  <p><?= $res['total'] ?> resultado(s)<?= $q !== '' ? ' para «' . e($q) . '»' : '' ?></p>

  <div class="grid">
    <?php foreach ($res['items'] as $p): ?>
      <article class="card">
        <h3><?= resaltar($p['nombre'], $q) ?></h3>
        <small><?= e($p['categoria'] ?? 'Sin categoría') ?></small>
        <p><strong>$<?= number_format((float) $p['precio'], 2) ?></strong></p>
      </article>
    <?php endforeach; ?>
  </div>

  <?= enlaces_paginacion($res['pagina'], $res['paginas']) ?>
</body>
</html>
<?php
/** Resalta el término buscado (escapando primero para evitar XSS) */
function resaltar(string $texto, string $q): string
{
    $seguro = e($texto);
    if ($q === '') return $seguro;
    return preg_replace('/' . preg_quote(e($q), '/') . '/iu', '<mark>$0</mark>', $seguro);
}

Búsqueda más potente: FULLTEXT ​

Con miles de registros, LIKE '%texto%' se vuelve lento porque no usa índices. MySQL tiene búsqueda de texto completo:

sql
ALTER TABLE productos ADD FULLTEXT INDEX ft_productos (nombre, descripcion);

-- Resultados ordenados por relevancia (:q1 y :q2 llevan el mismo valor;
-- con EMULATE_PREPARES=false no se puede repetir un placeholder)
SELECT id, nombre, MATCH(nombre, descripcion) AGAINST (:q1 IN NATURAL LANGUAGE MODE) AS relevancia
FROM productos
WHERE MATCH(nombre, descripcion) AGAINST (:q2 IN NATURAL LANGUAGE MODE)
ORDER BY relevancia DESC;

Búsqueda en vivo

Combina este endpoint con un fetch y el debounce de JavaScript para mostrar resultados mientras el usuario escribe.

Error común

Meter el valor de $_GET['orden'] directo en ORDER BY. Los placeholders ? no funcionan para nombres de columnas, así que la única defensa es una lista blanca como la de arriba.

Paginación por cursor

Para feeds infinitos o tablas enormes, OFFSET 100000 es lento. Usa paginación por cursor: WHERE id < :ultimo_id ORDER BY id DESC LIMIT 12.

Academia Web — aprende, copia y construye.