Пагинация новостей в Laravel: разбираем на живом примере
Список новостей на 40 тысяч записей начал грузиться по три секунды, потому что offset-пагинация под капотом читает всю таблицу. Разбираем, чем paginate отличается от cursorPaginate и когда что выбирать.

Задача звучит просто: вывести ленту новостей постранично. В документации Laravel есть метод paginate(), три строки в контроллере, готовый компонент ссылок в Blade — и всё работает. Пока в таблице пара сотен записей. На сороковой тысяче страница ?page=800 начинает открываться по 2–3 секунды, а база при этом читает почти всю таблицу целиком. Разберём, почему так и какие варианты пагинации в Laravel есть на самом деле.
Работаем на Laravel 11.x и MySQL 8 (для PostgreSQL всё сказанное тоже верно). Читатель предполагается умеющий писать контроллер и Blade-шаблон — базовую установку фреймворка не разбираем.
Почему обычный paginate тормозит на глубоких страницах
Метод paginate() строит запрос через LIMIT и OFFSET. Страница 800 при 50 записях на странице превращается в LIMIT 50 OFFSET 39950. База не умеет «перепрыгнуть» к 39950-й строке — она обязана отсортировать и пройти все 39950 строк, отбросить их и вернуть следующие 50. Чем глубже страница, тем дороже запрос.
Второй скрытый расход — paginate() делает дополнительный запрос COUNT(*), чтобы посчитать общее число страниц. На большой таблице с фильтрами этот COUNT сам по себе может быть тяжелее выборки.
Offset-пагинация линейно дешевеет к первой странице и линейно дорожает к последней. Если пользователи реально доходят до глубоких страниц — она вам не подходит.
Три подхода, между которыми выбираем
- paginate() — классические номера страниц с общим счётчиком. Пользователь видит «страница 5 из 812».
- simplePaginate() — только кнопки «назад» и «вперёд», без COUNT. Дешевле, но всё ещё через OFFSET.
- cursorPaginate() — курсорная пагинация по индексу. Не использует OFFSET вообще, работает через условие вида
WHERE id < ?.
paginate: полный номерной вариант
Контроллер для новостей выглядит так:
// app/Http/Controllers/NewsController.php
// Laravel 11.x
use App\Models\News;
use Illuminate\Http\Request;
public function index(Request $request)
{
$news = News::query()
->where('published', true)
->orderByDesc('published_at')
->paginate(20); // 20 новостей на страницу
return view('news.index', compact('news'));
}В шаблоне Blade выводим список и ссылки:
{{-- resources/views/news/index.blade.php --}}
@foreach ($news as $item)
<article>
<h2>{{ $item->title }}</h2>
<time>{{ $item->published_at->format('d.m.Y') }}</time>
<p>{{ Str::limit($item->excerpt, 200) }}</p>
</article>
@endforeach
{{ $news->links() }}Метод links() отдаёт готовую разметку. По умолчанию Laravel использует Tailwind-разметку; для Bootstrap вызовите Paginator::useBootstrapFive() в AppServiceProvider. Проверить, какой пресет доступен в вашей версии, стоит в разделе документации о пагинации.
Как понять, что работает
Откройте страницу — внизу списка появятся номера страниц. Перейдите на вторую, в адресе появится ?page=2, список сменится. Если вместо номеров пусто — проверьте, что записей больше, чем указано в paginate().
cursorPaginate: то, что нужно ленте новостей
Лента новостей почти всегда сортируется по дате и листается вперёд. Это идеальный случай для курсора. Замена в контроллере минимальная:
$news = News::query()
->where('published', true)
->orderByDesc('published_at')
->orderByDesc('id') // тай-брейкер: гарантирует стабильный порядок
->cursorPaginate(20);Шаблон не меняется — {{ $news->links() }} работает и здесь. Разница в SQL: вместо OFFSET 39950 запрос получает условие по значению последней записи предыдущей страницы. База использует индекс и читает ровно 20 строк независимо от глубины.
Важный нюанс: колонки в orderBy должны однозначно определять порядок. Если сортировать только по published_at, а у нескольких новостей одинаковое время публикации, курсор может пропустить или продублировать записи. Поэтому добавляют вторичную сортировку по уникальному полю — обычно по id. И на эти колонки нужен индекс, иначе выигрыша не будет.
Сравнение подходов
| Критерий | paginate() | simplePaginate() | cursorPaginate() |
|---|---|---|---|
| Номера страниц | Есть | Нет, только назад/вперёд | Нет, только назад/вперёд |
| Запрос COUNT(*) | Делает | Не делает | Не делает |
| Механизм | LIMIT/OFFSET | LIMIT/OFFSET | WHERE по индексу |
| Скорость на глубоких страницах | Падает | Падает | Стабильна |
| Прыжок на произвольную страницу | Можно | Нельзя | Нельзя |
Когда что выбирать
- Нужны номера страниц и переход на «страницу 40» — только
paginate(). Ни курсор, ни simple такого не дают: в них некуда прыгнуть, есть только соседние страницы. - Лента, бесконечный скролл, «показать ещё» —
cursorPaginate(). Пользователь всё равно листает подряд, а вы получаете стабильную скорость. - Небольшая таблица (до нескольких тысяч строк) и хочется номеров —
paginate()без раздумий, разница в скорости незаметна.
Что ломается
Типичные проблемы, на которые уходит время отладки:
- Пагинация ломается при JSON-ответе для фронта. Метод
links()генерирует HTML. Для API возвращайте$newsнапрямую или через ресурс — Laravel сам добавит метаданные с курсором или номерами страниц в полеmeta. - Фильтры и сортировка теряются при переходе на страницу 2. Ссылки пагинации не переносят GET-параметры автоматически. Вызовите
$news->withQueryString()перед выводом или$news->appends($request->query()), чтобы фильтры не сбрасывались. - cursorPaginate вернул не то и в другом порядке. Почти всегда причина — сортировка без уникального тай-брейкера. Добавьте
orderByпоid. - Курсор не ускорил запрос. Проверьте, что на колонки сортировки есть составной индекс. Без индекса база всё равно делает полный проход. Смотрите
EXPLAINпо запросу.
Что делать дальше
Снимите EXPLAIN с текущего запроса ленты на реальном объёме данных — увидите, читает база индекс или проходит таблицу целиком. Если планируете бесконечный скролл, переводите ленту на cursorPaginate() и добавляйте составной индекс на (published_at, id). Инвалидация кеша списков — отдельная тема, здесь мы её не трогали; но если счётчик COUNT в paginate() стал узким местом, его результат имеет смысл кешировать отдельно от самой выборки.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Laravel Documentation — Database: Pagination
Частые вопросы
У меня Laravel 9, cursorPaginate тоже есть?
Почему после смены paginate на cursorPaginate пропали номера страниц?
Как перенести фильтры при переходе на другую страницу?
cursorPaginate не ускорил запрос — что не так?
Как отдать пагинацию во фронтенд как JSON, а не HTML?
Можно ли менять число записей на странице через параметр запроса?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.