Механизм пагинации в веб-приложениях на PHP через индекс p
Реализация пагинации в PHP
Какое решение пагинации является наиболее эффективным и безопасным?
Основной подход использует запросы SQL с LIMIT и OFFSET через PDO. Номер страницы передаётся как p в URL. Вычисляется смещение: offset = (page - 1) * perPage. Общее количество записей получается отдельным запросом COUNT(*). Это даёт число страниц ceil(total / perPage). Затем формируются ссылки на каждую страницу.
<?php
$perPage = 10;
$currentPage = isset($_GET['p']) ? (int)$_GET['p'] : 1;
if ($currentPage < 1) $currentPage = 1;
$offset = ($currentPage - 1) * $perPage;
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
$totalStmt = $pdo->query("SELECT COUNT(*) FROM articles");
$total = $totalStmt->fetchColumn();
$totalPages = ceil($total / $perPage);
$stmt = $pdo->prepare("SELECT * FROM articles ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Вывод статей
foreach ($rows as $row) {
echo '<div class="fw-bold">' . htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') . '</div>';
}
// Пагинация
if ($totalPages > 1) {
for ($i = 1; $i <= $totalPages; $i++) {
$active = ($i == $currentPage) ? ' class="active"' : '';
echo '<a href="?p=' . $i . '"' . $active . '>' . $i . '</a> ';
}
}
?>
Index php p 2 (пагинация в php через p=2)
Цель: безопасная и производительная работа с базами данных. Случай использования: проекты с умеренным объёмом данных (до нескольких сотен тысяч записей).
Типичные ошибки:
- Использование непроверенного GET-параметра p в SQL без подготовки – уязвимость для инъекций. Решение: PDO с prepared statements и привязка параметров.
- Отсутствие проверки на отрицательные и нулевые значения p – может привести к некорректному OFFSET. Решение: if ($currentPage < 1) $currentPage = 1;
- Необработанное превышение максимальной страницы – пользователь может ввести p=999. Решение: ограничить p значением totalPages или перенаправить.
- Забытый вызов htmlspecialchars при выводе данных – XSS-уязвимость.
Как реализовать пагинацию с использованием курсора для больших таблиц?
Для таблиц с миллионами записей страдает производительность из-за OFFSET. Альтернатива – пагинация на основе курсора (keyset pagination). Вместо номера страницы передаётся значение последнего ID предыдущей страницы.
<?php
$perPage = 10;
$lastId = isset($_GET['last']) ? (int)$_GET['last'] : 0;
$stmt = $pdo->prepare("SELECT * FROM articles WHERE id > :last ORDER BY id ASC LIMIT :limit");
$stmt->bindValue(':last', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (count($rows) > 0) {
$lastId = end($rows)['id'];
echo '<a href="?last=' . $lastId . '">Следующая страница</a>';
}
?>
Цель: высокая скорость при больших наборах данных. Случай использования: ленты новостей, логи, где важна производительность.
Проблемы:
- Невозможность перейти на произвольную страницу (например, 5). Решение: комбинировать с нумерацией, если требуется.
- Зависимость от уникального упорядоченного столбца (обычно id).
Как отобразить пагинацию с диапазоном и многоточием (например, 1 ... 5 6 7 ... 20)?
Когда число страниц велико, удобно показывать только их часть. Реализуется расчётом начала и конца блока с учётом текущей страницы.
<?php
$currentPage = isset($_GET['p']) ? max(1, (int)$_GET['p']) : 1;
$totalPages = 20;
$range = 2; // количество ссылок слева и справа от активной
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
if ($start > 1) {
echo '<a href="?p=1">1</a> ... ';
$start = $start + 0; // вывод многоточия отдельно
}
for ($i = $start; $i <= $end; $i++) {
$active = ($i == $currentPage) ? ' class="active"' : '';
echo '<a href="?p='.$i.'"'.$active.'>'.$i.'</a> ';
}
if ($end < $totalPages) {
echo '... <a href="?p='.$totalPages.'">'.$totalPages.'</a>';
}
?>
Ошибки:
- Неправильный расчёт границ при близости к началу или концу. Решение: использовать max и min.
- Дублирование многоточия при малом totalPages. Условиями проверить > 1.
Как реализовать динамическую пагинацию через AJAX (без перезагрузки страницы)?
Используется JavaScript для отправки запроса на сервер и вставки полученного HTML. Сервер возвращает только фрагмент данных и ссылки пагинации.
// PHP (ajax_pagination.php)
$perPage = 10;
$currentPage = isset($_GET['p']) ? (int)$_GET['p'] : 1;
if ($currentPage < 1) $currentPage = 1;
$offset = ($currentPage - 1) * $perPage;
$pdo = new PDO(/* ... */);
$total = $pdo->query("SELECT COUNT(*) FROM articles")->fetchColumn();
$totalPages = ceil($total / $perPage);
$stmt = $pdo->prepare("SELECT * FROM articles LIMIT :limit OFFSET :offset");
/* ... выполнение и fetchAll */
// Возвращаем JSON или HTML
$html = '';
foreach ($rows as $row) {
$html .= '<div>' . htmlspecialchars($row['title']) . '</div>';
}
$html .= '<div class="pagination">';
for ($i = 1; $i <= $totalPages; $i++) {
$active = ($i == $currentPage) ? ' class="active"' : '';
$html .= '<a href="#" data-page="'.$i.'"'.$active.'>'.$i.'</a> ';
}
$html .= '</div>';
echo $html;
// JavaScript (jQuery)
$('.pagination a').on('click', function(e) {
e.preventDefault();
var page = $(this).data('page');
$.get('ajax_pagination.php', {p: page}, function(response) {
$('#content').html(response);
});
});
Цель: улучшение пользовательского опыта. Случай использования: интернет-магазины, каталоги.
Типичные проблемы:
- Кэширование AJAX-запросов – нужно отключать или добавлять случайный параметр.
- Некорректная обработка истории браузера (кнопка «Назад»). Решение: использовать History API.
- Ошибки при большом количестве одновременных запросов.
Как сохранить дополнительные GET-параметры (фильтры, сортировку) при пагинации?
Часто пагинация должна работать вместе с другими параметрами. В ссылки нужно включать их через http_build_query.
<?php
$params = $_GET;
unset($params['p']); // удаляем текущую страницу
for ($i = 1; $i <= $totalPages; $i++) {
$params['p'] = $i;
$url = '?' . http_build_query($params);
$active = ($i == $currentPage) ? ' class="active"' : '';
echo '<a href="' . $url . '"' . $active . '>' . $i . '</a> ';
}
?>
Ошибки:
- Передача чувствительных данных в URL (например, паролей). Этого следует избегать.
- Дублирование параметров, если не удалить p перед построением.
Расширенные примеры пагинации
Полноценный класс Pagination с поддержкой всех параметров
<?php
class Pagination {
private $total;
private $perPage;
private $currentPage;
private $urlPattern;
public function __construct($total, $perPage = 10, $currentPage = 1, $urlPattern = '?p=(:num)') {
$this->total = $total;
$this->perPage = $perPage;
$this->currentPage = max(1, $currentPage);
$this->urlPattern = $urlPattern;
}
public function getTotalPages() {
return ceil($this->total / $this->perPage);
}
public function getOffset() {
return ($this->currentPage - 1) * $this->perPage;
}
public function getLimit() {
return $this->perPage;
}
public function buildUrl($page) {
return str_replace('(:num)', $page, $this->urlPattern);
}
public function render($range = 2) {
$totalPages = $this->getTotalPages();
if ($totalPages <= 1) return '';
$html = '<div class="pagination">';
$html .= $this->renderArrow($this->currentPage - 1, '«');
$start = max(1, $this->currentPage - $range);
$end = min($totalPages, $this->currentPage + $range);
if ($start > 1) {
$html .= '<a href="'.$this->buildUrl(1).'">1</a>';
if ($start > 2) $html .= '<span class="dots">…</span>';
}
for ($i = $start; $i <= $end; $i++) {
$active = ($i == $this->currentPage) ? ' class="active"' : '';
$html .= '<a href="'.$this->buildUrl($i).'"'.$active.'>'.$i.'</a>';
}
if ($end < $totalPages) {
if ($end < $totalPages - 1) $html .= '<span class="dots">…</span>';
$html .= '<a href="'.$this->buildUrl($totalPages).'">'.$totalPages.'</a>';
}
$html .= $this->renderArrow($this->currentPage + 1, '»');
$html .= '</div>';
return $html;
}
private function renderArrow($page, $label) {
$totalPages = $this->getTotalPages();
if ($page < 1 || $page > $totalPages) {
return '<span class="disabled">'.$label.'</span>';
}
return '<a href="'.$this->buildUrl($page).'">'.$label.'</a>';
}
}
?>
Пример использования класса:
<?php
$pdo = new PDO(/* ... */);
$total = $pdo->query("SELECT COUNT(*) FROM articles")->fetchColumn();
$currentPage = isset($_GET['p']) ? (int)$_GET['p'] : 1;
$pagination = new Pagination($total, 10, $currentPage, '?p=(:num)&sort=date');
$offset = $pagination->getOffset();
$limit = $pagination->getLimit();
$stmt = $pdo->prepare("SELECT * FROM articles ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Вывод данных...
foreach ($rows as $row) { /* ... */ }
// Вывод пагинации
echo $pagination->render(3);
?>
Результат (пример HTML):
<div class="pagination">
<a href="?p=1&sort=date">«</a>
<span class="dots">…</span>
<a href="?p=4&sort=date">4</a>
<a href="?p=5&sort=date" class="active">5</a>
<a href="?p=6&sort=date">6</a>
<span class="dots">…</span>
<a href="?p=20&sort=date">»</a>
</div>
Пагинация с сохранением порядка сортировки (несколько сортирующих параметров)
<?php
$allowedSorts = ['title', 'created_at', 'views'];
$sort = isset($_GET['sort']) && in_array($_GET['sort'], $allowedSorts) ? $_GET['sort'] : 'created_at';
$order = isset($_GET['order']) && $_GET['order'] === 'asc' ? 'ASC' : 'DESC';
$queryParams = $_GET;
unset($queryParams['p']);
for ($i = 1; $i <= $totalPages; $i++) {
$queryParams['p'] = $i;
$url = '?' . http_build_query($queryParams);
// ... вывод ссылок
}
?>
Результат: ссылки вида ?sort=created_at&order=DESC&p=2.