Tema
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.