Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Пагінація

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

6 питань

Достатньо викликати paginate() замість get() - Laravel сам читає номер сторінки з query-параметра ?page=:

$posts = Post::latest()->paginate(15);
@foreach ($posts as $post) ... @endforeach
{{ $posts->links() }}   {{-- кнопки навігації --}}
  • paginate() рахує загальну кількість (показує «сторінка X з Y»).
  • simplePaginate() - лише «вперед/назад», без COUNT(*), швидше на великих таблицях.
  • cursorPaginate() - курсорна пагінація, найефективніша для нескінченного скролу.

Докладніше в документації: Пагінація

paginate() робить два запити:

select count(*) as aggregate from posts where published = true;   -- загальна кількість
select * from posts where published = true order by id desc limit 15 offset 30;

Завдяки кількості він знає, скільки всього сторінок, і може показати навігацію «1 2 3 ... 48» та «Показано 31-45 з 712».

simplePaginate() робить один запит і бере на один запис більше:

select * from posts where published = true order by id desc limit 16 offset 30;

Якщо повернувся 16-й запис - наступна сторінка існує. Загальної кількості він не знає, тому показує лише «Попередня» і «Наступна».

$posts = Post::where('published', true)->latest('id')->simplePaginate(15);

Порівняння:

paginate() simplePaginate()
запитів 2 (з count) 1
номери сторінок так ні
загальна кількість $posts->total() недоступна
$posts->lastPage() так ні
швидкість на великих таблицях count(*) може бути дорогим швидше

Коли обирати simplePaginate():

  • стрічки й списки, де користувач гортає послідовно: новини, коментарі, історія дій;
  • кнопка «Завантажити ще»;
  • великі таблиці, де count(*) зі складними умовами займає помітний час;
  • API для мобільних застосунків, яким номери сторінок не потрібні.

Коли paginate(): таблиці в адмінці, результати пошуку з переходом на конкретну сторінку, місця, де потрібно показати загальну кількість.

Третій варіант - cursorPaginate(): теж без загальної кількості, але замість offset використовує умову за ключем сортування - швидкий навіть на далеких сторінках. Але не дозволяє перейти на довільну сторінку.

Відображення однакове: {{ $posts->links() }} для обох; для простої пагінації Laravel використовує окремий шаблон лише з двома кнопками.

Докладніше в документації: Проста пагінація

Класична скарга: користувач вибрав фільтри, перейшов на другу сторінку - і фільтри злетіли. Причина в тому, що посилання пагінації за замовчуванням несуть лише page.

Додати поточні параметри:

$posts = Post::filter($request)->paginate(15)->withQueryString();

withQueryString() переносить усі параметри рядка запиту в посилання пагінації. Якщо потрібні конкретні - appends():

$posts->appends(['sort' => $request->input('sort')]);

Друга частина проблеми - сортування без унікального ключа. Якщо сортувати за колонкою з повторами, рядки з однаковим значенням база може віддати в різному порядку на різних сторінках - і один запис зʼявиться двічі, а інший зникне:

// хитко: багато записів з однаковою датою
Post::orderByDesc('published_at')->paginate(15);

// стабільно: дозволяємо ключем
Post::orderByDesc('published_at')->orderByDesc('id')->paginate(15);

Третя - сторінка поза межами. Після зміни фільтра запис може стати менше, ніж потрібно для сторінки 7, і користувач бачить порожньо. Скидання номера сторінки при зміні фільтра вирішує це; у Livewire для того є resetPage().

Для SEO варто памʼятати ще про одне: кожна комбінація фільтрів із номером сторінки - окрема URL. Якщо їх багато, сторінки з фільтрами зазвичай закривають від індексації, лишаючи в ній базовий список.

Докладніше в документації: Пагінація

Скільки номерів показувати навколо поточної сторінки:

{{ $users->onEachSide(1)->links() }}

За замовчуванням - по три з кожного боку. На мобільних onEachSide(1) робить навігацію компактною.

Вбудовані шаблони: за замовчуванням Laravel генерує розмітку для Tailwind CSS. Для Bootstrap - один раз у провайдері:

// AppServiceProvider::boot()
Paginator::useBootstrapFive();

Власний шаблон для всього застосунку:

php artisan vendor:publish --tag=laravel-pagination

Шаблони копіюються в resources/views/vendor/pagination, і їх можна змінювати. Або вказати свої за замовчуванням:

Paginator::defaultView('pagination.compact');
Paginator::defaultSimpleView('pagination.simple-compact');

Окремий шаблон для конкретного місця:

{{ $users->links('pagination.admin') }}

Параметри в посиланнях:

$users = User::paginate(20)->withQueryString();          // зберегти поточні фільтри
$users = User::paginate(20)->appends(['sort' => 'name']); // додати свої параметри
$users = User::paginate(20)->fragment('users');           // #users наприкінці посилань
$users = User::paginate(20)->withPath('/admin/users');    // інший базовий шлях

fragment корисний, коли список - не на початку сторінки: після переходу браузер прокручує до потрібного блоку.

Кілька пагінаторів на одній сторінці - різні назви параметра, інакше обидва реагують на ?page=:

$posts = Post::paginate(10, pageName: 'posts');
$comments = Comment::paginate(10, pageName: 'comments');

Доступні дані в шаблоні: $paginator->currentPage(), lastPage(), total(), firstItem(), lastItem(), hasMorePages(), previousPageUrl(), nextPageUrl() - з них можна зібрати навігацію будь-якого вигляду.

SEO й доступність у власному шаблоні: справжні посилання <a href> (а не кнопки з JavaScript), rel="prev"/rel="next", aria-current="page" для поточної сторінки, aria-label для стрілок.

Докладніше в документації: Налаштування вигляду пагінації

Laravel дає три способи гортати вибірку, і вони по-різному поводяться на великих таблицях.

paginate() - класична пагінація з номерами сторінок:

$posts = Post::latest()->paginate(15);

Виконує два запити: сам вибір і count(*) для загальної кількості сторінок.

simplePaginate() - те саме без підрахунку загальної кількості, лише «далі/назад». Прибирає дорогий count(*) на великій таблиці.

cursorPaginate() - гортання за значенням ключа, без offset:

$posts = Post::latest()->cursorPaginate(15);

Чому offset повільний. limit 15 offset 100000 не перестрибує рядки - база читає й відкидає сто тисяч, перш ніж віддати п'ятнадцять. Що глибша сторінка, то повільніше; на останніх сторінках великого списку це секунди.

Курсорна пагінація натомість запитує «наступні 15 після цього значення»:

where (published_at, id) < (?, ?) order by published_at desc, id desc limit 15

Час не залежить від глибини - за умови, що є індекс на колонках сортування.

Друга перевага - стабільність. З offset новий запис, доданий між переглядами сторінок, зсуває всю вибірку, і один запис читач побачить двічі, а інший пропустить. Курсор до цього стійкий.

Ціна курсора: немає номерів сторінок і переходу «на сторінку 7» - лише вперед і назад. Сортування має бути за унікальною чи доповненою id комбінацією, інакше рядки з однаковим значенням загубляться.

Що обирати: адмінка з номерами сторінок - paginate(); довга стрічка чи API - cursorPaginate(); проміжний варіант, де потрібні лише «далі/назад» - simplePaginate().

Докладніше в документації: Пагінація

paginate() має порахувати, скільки рядків поверне запит. Для простого запиту це select count(*) from ..., але зі складними запитами виникають дві проблеми.

1. GROUP BY чи HAVING. count(*) над запитом з групуванням повертає кількість у кожній групі, а не кількість груп. Laravel це враховує: якщо в запиті є groupBy чи having, він обгортає запит у підзапит:

select count(*) as aggregate from (
    select customer_id, sum(total) from orders group by customer_id
) as aggregate_table

Результат правильний, але база виконує весь запит з групуванням лише для підрахунку - на великих даних це повільно.

2. DISTINCT, об'єднання з дублями, UNION - підрахунок або дорогий, або без уважності дає неправильне число.

Варіанти розв'язання:

Не рахувати взагалі - simplePaginate() чи cursorPaginate(). Для більшості стрічок і звітів кнопок «далі/назад» достатньо.

Передати кількість самому - paginate() приймає параметр total:

$total = Cache::remember('reports:customers:count', 300, fn () => DB::table('orders')->distinct()->count('customer_id'));

$customers = DB::table('orders')
    ->select('customer_id', DB::raw('sum(total) as revenue'))
    ->groupBy('customer_id')
    ->orderByDesc('revenue')
    ->paginate(perPage: 50, total: $total);

Кількість груп можна порахувати простішим запитом (count(distinct customer_id)), закешувати чи взяти з денормалізованого лічильника.

Приблизна кількість: для «приблизно 1,2 млн результатів» - оцінка з EXPLAIN чи статистики таблиці (reltuples у PostgreSQL) замість точного count.

Перенести групування в окрему таблицю: для звітів, які відкривають часто, - матеріалізоване подання чи таблиця агрегатів, що оновлюється за розкладом. Тоді пагінація йде по простій таблиці з індексом.

Обмежити глибину: пошукові системи показують лише перші сотні результатів. Обмеження на кількість сторінок прибирає і дорогий підрахунок, і повільні далекі OFFSET.

Пастки з Eloquent:

  • paginate() з select і groupBy вимагає, щоб усі неагреговані колонки були в GROUP BY (PostgreSQL і MySQL з ONLY_FULL_GROUP_BY);
  • withCount і having разом з пагінацією - перевіряйте згенерований SQL через toRawSql();
  • порядок має бути детермінованим (orderBy('revenue')->orderBy('customer_id')), інакше записи з однаковим значенням перескакують між сторінками.

Докладніше в документації: Пагінація запитів