Организация постраничного вывода тем на PHP-форуме

Раздел: Разработка сайтов -> Форумы и обсуждения

Пагинация для форума на PHP

Пагинация (разбивка на страницы) необходима для удобной навигации по большому количеству тем или сообщений на форуме. Рассмотрим различные подходы к реализации второй страницы (forum.php?page=2).

Как организовать постраничный вывод тем с помощью SQL LIMIT и OFFSET?

Это базовое и наиболее производительное решение для большинства форумов. Используется параметр page из строки запроса, на основе которого вычисляется смещение (OFFSET).

// Получение номера страницы (по умолчанию 1)
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$perPage = 20; // количество записей на странице
$offset = ($page - 1) * $perPage;

// Запрос к базе данных (PDO)
$stmt = $pdo->prepare('SELECT * FROM topics 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();
$topics = $stmt->fetchAll();

// Получение общего количества тем для расчета числа страниц
$totalStmt = $pdo->query('SELECT COUNT(*) FROM topics');
$totalTopics = $totalStmt->fetchColumn();
$totalPages = ceil($totalTopics / $perPage);

Вывод ссылок на страницы можно организовать через цикл:

for ($i = 1; $i <= $totalPages; $i++) {
    echo '' . $i . ' ';
}

Этот подход подходит для форумов с умеренным числом записей (до нескольких десятков тысяч). Для больших массивов данных рекомендуется использовать курсорную пагинацию.

Типичные ошибки: неэкранирование GET-параметра (SQL-инъекция) – всегда приводите к целому числу через (int) или intval(). Неверное вычисление $offset при $page < 1 – добавьте проверку $page = max(1, $page). Также легко забыть про LIMIT и получить всю выборку.

Как загружать вторую страницу через AJAX без обновления всей страницы?

Этот вариант улучшает пользовательский опыт: сообщения подгружаются динамически. На сервере отдаётся JSON с данными тем.

// PHP (ajax.php)
$page = isset($_GET['page']) ? (int)$_GET['page'] : 2;
$perPage = 20;
$offset = ($page - 1) * $perPage;
$stmt = $pdo->prepare('SELECT id, title, created_at FROM topics LIMIT :limit OFFSET :offset');
$stmt->execute([':limit' => $perPage, ':offset' => $offset]);
$topics = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($topics);

На клиенте (JavaScript) отправляется запрос и данные вставляются в DOM:

fetch('ajax.php?page=2')
    .then(response => response.json())
    .then(data => {
        data.forEach(topic => {
            const div = document.createElement('div');
            div.textContent = topic.title;
            document.getElementById('topics').appendChild(div);
        });
    });

Подходит для интерактивных форумов, где важна скорость переключения. Недостаток: проблемы с SEO (контент не индексируется без дополнительной настройки).

Ошибки: забывают обработать ошибки выборки (статус ответа), не проверяют существование данных (пустой массив). При прямом доступе к ajax.php может раскрыться вся база – ограничьте доступ по рефереру или ключу.

Как реализовать пагинацию с помощью курсора (cursor-based) для очень большого форума?

Классический OFFSET замедляется при больших смещениях, так как база данных всё равно сканирует все строки до указанного смещения. Курсорная пагинация использует уникальный ключ (например, id или created_at) для перехода к следующему блоку.

Принцип: клиент передаёт значение последнего элемента предыдущей страницы (например, after_id). Сервер возвращает записи с id > after_id.

// PHP
$afterId = isset($_GET['after']) ? (int)$_GET['after'] : 0;
$limit = 20;
$stmt = $pdo->prepare('SELECT * FROM topics WHERE id > :after_id ORDER BY id ASC LIMIT :limit');
$stmt->execute([':after_id' => $afterId, ':limit' => $limit]);
$topics = $stmt->fetchAll();

В ответе клиенту передаётся next_id (максимальный id в выборке). Этот метод крайне эффективен, стабилен при вставке новых записей (нет дублирования/пропуска).

Проблемы: сложность навигации (нет общего числа страниц, нельзя перейти на любую страницу). Требуется передача дополнительных параметров (after) в каждом запросе. Подходит только для бесконечной ленты («загрузить ещё»).

Расширенные примеры пагинации для форума

Приведём полные реализации с обработкой краевых случаев и дополнительными возможностями.

Класс Pagination с генерацией HTML-ссылок

Следующий класс формирует блок постраничной навигации с ссылками «Первая», «Предыдущая», номера вокруг текущей страницы, «Следующая», «Последняя».

Пример
class Pagination {
    private $totalPages;
    private $currentPage;
    private $url;

    public function __construct($totalItems, $perPage, $currentPage, $url = '') {
        $this->totalPages = (int)ceil($totalItems / $perPage);
        $this->currentPage = max(1, min($currentPage, $this->totalPages));
        $this->url = $url ?: $_SERVER['PHP_SELF'];
    }

    public function render() {
        if ($this->totalPages <= 1) return '';
        $html = '<nav><ul class="pagination">';
        // Первая страница
        if ($this->currentPage > 1) {
            $html .= '<li><a href="' . $this->url . '?page=1">Первая</a></li>';
            $html .= '<li><a href="' . $this->url . '?page=' . ($this->currentPage - 1) . '">Предыдущая</a></li>';
        }
        // Диапазон номеров
        $start = max(1, $this->currentPage - 2);
        $end = min($this->totalPages, $this->currentPage + 2);
        for ($i = $start; $i <= $end; $i++) {
            $active = ($i == $this->currentPage) ? ' class="active"' : '';
            $html .= '<li' . $active . '><a href="' . $this->url . '?page=' . $i . '">' . $i . '</a></li>';
        }
        // Следующая и последняя
        if ($this->currentPage < $this->totalPages) {
            $html .= '<li><a href="' . $this->url . '?page=' . ($this->currentPage + 1) . '">Следующая</a></li>';
            $html .= '<li><a href="' . $this->url . '?page=' . $this->totalPages . '">Последняя</a></li>';
        }
        $html .= '</ul></nav>';
        return $html;
    }
}

Пример использования:

Пример
$totalTopics = 156;
$perPage = 10;
$current = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$pagination = new Pagination($totalTopics, $perPage, $current);
echo $pagination->render();
Результат (HTML):
<nav><ul class="pagination"><li><a href="/forum.php?page=1">Первая</a></li>...</ul></nav>

Пагинация с сортировкой по дате и поиском

Добавим в запрос условие поиска и сортировку. Параметры поиска и страницы должны передаваться в URL.

Пример
$keyword = isset($_GET['q']) ? trim($_GET['q']) : '';
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$perPage = 10;
$offset = ($page - 1) * $perPage;

$where = '';
$params = [];
if ($keyword) {
    $where = 'WHERE title LIKE :keyword';
    $params[':keyword'] = '%' . $keyword . '%';
}

// Общее количество
$countSql = "SELECT COUNT(*) FROM topics $where";
$countStmt = $pdo->prepare($countSql);
$countStmt->execute($params);
$total = $countStmt->fetchColumn();

// Выборка текущей страницы
$sql = "SELECT * FROM topics $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
foreach ($params as $key => $val) {
    $stmt->bindValue($key, $val);
}
$stmt->execute();
$topics = $stmt->fetchAll();

// Формирование ссылок с сохранением поискового запроса
$queryString = http_build_query(array_merge($_GET, ['page' => '']));
$url = $_SERVER['PHP_SELF'] . '?' . $queryString;
// Далее используется Pagination с подстановкой $url

Такой код гарантирует, что при переходе на другую страницу параметр q не потеряется. Обратите внимание на использование http_build_query для корректного формирования строки запроса.

Пример URL: /forum.php?q=php&page=2

AJAX-пагинация с поддержкой истории браузера (History API)

Улучшенный вариант, когда при клике на страницу URL изменяется, и пользователь может вернуться на предыдущую страницу. Сервер отдает HTML-блок, а JavaScript подменяет содержимое.

Пример
// JavaScript
function loadPage(page) {
    fetch('ajax.php?page=' + page + '&html=1')
        .then(response => response.text())
        .then(html => {
            document.getElementById('topics-wrapper').innerHTML = html;
            history.pushState({page: page}, '', '?page=' + page);
        });
}

// Обработка клика по ссылкам пагинации
$(document).on('click', '.pagination a', function(e) {
    e.preventDefault();
    const page = $(this).data('page'); // или из href
    loadPage(page);
});

// Восстановление состояния при нажатии назад
window.addEventListener('popstate', function(e) {
    if (e.state && e.state.page) {
        loadPage(e.state.page);
    }
});

На сервере (ajax.php) при наличии параметра html=1 возвращается не JSON, а готовый HTML-фрагмент (например, список тем). Это упрощает обработку на клиенте.

Пример
if (isset($_GET['html'])) {
    // вывод HTML-блока
    foreach ($topics as $topic) {
        echo '<div class="topic">' . htmlspecialchars($topic['title']) . '</div>';
    }
    exit;
}

Такой подход сочетает удобство AJAX и сохраняемость навигации.

- Topic php topic page (тема на php)
- Forum php page 2 (форум на php)

Форум на PHP - comments

En
Forum php page 2 (php)