0% прочитано

Laravel Feed: як змішати Posts, News і Tips за датою та не зламати пагінацію

Як у ICanUp я об’єднала Posts, News і Tips в одну хронологічну стрічку через UNION ALL, стабільне сортування та cursor pagination, а повні Eloquent-моделі завантажую вже після вибору поточної сторінки.

24 вересня 2026 р. 10 хв читанняЛаравель

Після появи News і Tips у ICanUp я хотіла показувати їх у тій самій стрічці, де вже були Posts. З боку інтерфейсу задача звучить просто: взяти три списки, змішати за датою і показати.

Але пагінація змінює задачу. Якщо кожен тип контенту пагінувати окремо, а потім змішувати в PHP, межа сторінки перестає бути спільною. Якщо сортувати тільки за published_at, записи з однаковим часом не мають гарантованого порядку. А якщо одразу тягнути повні Eloquent-моделі з усіма relations, feed швидко перетворюється на складний і дорогий запит.

Спочатку одна мінімальна проєкція

Що потрібно спільній стрічці

На етапі сортування feed не потрібні title, Media, category або tags. Йому потрібні лише поля, які визначають ідентичність та позицію запису.

feed_type

Показує, з якої сутності походить рядок: post, news або tip.

entity_id

Дає стабільний ідентифікатор усередині конкретного типу контенту.

published_at

Створює спільну часову шкалу для всіх трьох таблиць.

type_order

Визначає порядок між різними типами, якщо час публікації однаковий.

PHP - Feed projections for Post, News and Tip
private function postProjection(array $filters): Builder{    return Post::query()        ->tap(new PublishedOnly)        ->tap(new PublicPostFilter(FilterParams::make($filters)))        ->selectRaw(            '? as feed_type, posts.id as entity_id, posts.published_at as published_at, ? as type_order',            [                FeedItemTypeEnum::POST->value,                FeedItemTypeEnum::POST->sortOrder(),            ],        );} private function newsProjection(string $locale): Builder{

Кожна сутність зберігає власні правила публічної видимості. Posts проходять через PublishedOnly і PublicPostFilter, News використовують viewableInLocale(), а Tips - publiclyVisible().

Це важливий момент: unified feed не повинен створювати ще одну, трохи іншу версію правил публікації. Проєкція лише приводить уже дозволені записи до спільної форми.

Чому published_at недостатньо

До

Нестабільно: ORDER BY published_at DESC.

Два або більше записи можуть мати однаковий timestamp. База даних не зобов’язана повертати їх у тому самому внутрішньому порядку між запитами. Для cursor pagination це означає слабку межу сторінки.

Після

Детерміновано: published_at DESC, type_order DESC, entity_id DESC.

Кожен рядок отримує повну позицію на спільній шкалі. Cursor може однозначно описати останній елемент сторінки.

PHP - Deterministic type order
enum FeedItemTypeEnum: string{    case POST = 'post';    case NEWS = 'news';    case TIP = 'tip';     public function sortOrder(): int    {        return match ($this) {            self::POST => 30,            self::NEWS => 20,            self::TIP => 10,        };    }}
У ICanUp при однаковому published_at порядок типів фіксований: Post = 30, News = 20, Tip = 10. Після type_order останнім tie-breaker стає entity_id. Важливі не самі числа, а те, що правило явне і стабільне.

Cursor pagination працює вже поверх UNION

PHP - UNION ALL with stable cursor pagination
$projection = $this->postProjection($filters); if (! filled($filters['category'] ?? null)) {    $projection        ->unionAll($this->newsProjection($locale))        ->unionAll($this->tipProjection($locale));} $paginator = DB::query()    ->fromSub($projection, 'feed_projection')    ->select([        'feed_type',        'entity_id',        'published_at',        'type_order',    ])

Тут для мене була ключова різниця між трьома списками і однією стрічкою. Cursor створюється не окремо для Posts, News і Tips. Він працює вже над результатом UNION ALL, тобто над однією впорядкованою послідовністю.

Тому сторінка з 12 елементів може містити будь-яку комбінацію типів, а наступний запит продовжується після останньої позиції саме цієї спільної послідовності.

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

Не тягнути повні моделі через UNION

Дві фази замість одного монструозного запиту

UnifiedFeedService спочатку визначає склад і порядок сторінки, а потім окремо завантажує повні Eloquent-моделі.

  1. Побудувати легкі проєкції

    Кожен тип повертає тільки feed_type, entity_id, published_at і type_order.

  2. Зробити UNION ALL

    Три джерела стають однією SQL-послідовністю з єдиним набором колонок.

  3. Відсортувати й відрізати сторінку

    Cursor pagination працює до завантаження важких relations, тому ми вже знаємо точні IDs поточної сторінки.

  4. Згрупувати IDs за типом

    Posts, News і Tips отримують власні списки ідентифікаторів тільки для цієї сторінки.

  5. Hydrate кожен тип окремо

    Кожен Eloquent query завантажує тільки свої relations і потім результати повертаються в початковий feed order.

PHP - Group feed rows before hydration
$ids = $rows    ->groupBy('feed_type')    ->map(        static fn (Collection $items): array => $items            ->pluck('entity_id')            ->map(static fn (mixed $id): int => (int) $id)            ->all(),    ); $posts = $this->hydratePosts(    ids: $ids->get(        FeedItemTypeEnum::POST->value,        [],    ),    locale: $locale,    filters: $filters,
PHP - Eager loading only for selected Posts
return Post::query()    ->with([        'translations' => fn ($query) => $query            ->where('locale', $locale),        'category.translations' => fn ($query) => $query            ->where('locale', $locale),        'imageMedia',        'postSeries' => fn ($query) => $query            ->active()            ->with([                'translations' => fn ($query) => $query                    ->where('locale', $locale),            ]),        'readings' => fn ($query) => $query            ->tap(new ForCurrentReader),    ])

Для Posts у ICanUp завантажуються локалізовані translations, category, cover Media, активна Series і reading state поточного читача. News завантажують свої translations та imageMedia. Tips - translations, topic, tags і imageMedia.

Тобто feed не намагається зробити три різні моделі однаковими на рівні Eloquent. Спільним є лише порядок сторінки. Деталі кожного типу залишаються у його власному query.

Один API contract, різні картки

PHP - Normalize different entities for the feed
return match ($item->type) {    FeedItemTypeEnum::POST =>        $this->postPayload($item, $request),     FeedItemTypeEnum::NEWS =>        $this->newsPayload($item, $request),     FeedItemTypeEnum::TIP =>        $this->tipPayload($item, $request),}; // Shared top-level contract:// key// id// type// publishedAt

Frontend отримує однакові базові поля, але кожен тип контенту зберігає власну специфіку.

Тип

Що залишається специфічним

Post

Category, Series, reading time, views, comments і reading progress.

News

Власний type, label, tone та public URL.

Tip

Type, topic, tags та власна компактна картка.

Фільтр категорії в ICanUp має окрему семантику: коли користувач обирає category, unified feed повертає лише Posts цієї категорії. News і Tips не мають Post category, тому вони не домішуються у відфільтрований список.

Тести для межі сторінки важливіші за happy path

PHP - Cursor pagination regression test
$firstPage = $service->paginate(    locale: 'uk',    perPage: 2,); expect(    $firstPage        ->getCollection()        ->map(            fn ($item): array => [                $item->type->value,                $item->entity->id,            ]        )        ->all(),)->toBe([

В інтеграційних тестах я окремо перевіряю не лише порядок Post -> News -> Tip, а й перехід через cursor на наступну сторінку, однаковий published_at, правила видимості, category filter, нормалізований resource contract і відсутність додаткових SQL-запитів під час серіалізації вже hydrated сторінки.

Остання перевірка особливо корисна проти N+1: після paginate() серіалізація feed items не повинна раптом почати підвантажувати relations по одному.

Коротко про unified feed

Кілька рішень, які виявилися важливими саме для цього кейсу.

Чому не paginate() після merge() у PHP?
Тому що для правильної сторінки спочатку потрібно знати глобальний порядок усіх трьох джерел. Якщо кожне джерело вже відрізало власну сторінку, частина кандидатів на спільну сторінку може взагалі не потрапити в merge.
Чому UNION ALL, а не UNION?
Feed rows мають власну ідентичність через feed_type та entity_id, тому SQL-дедуплікація між різними джерелами тут не потрібна. UNION ALL не додає зайву роботу з усунення дублікатів.
Навіщо type_order, якщо є entity_id?
entity_id у різних таблицях не задає порядок між типами. type_order спочатку визначає, який тип іде першим при однаковому published_at, а entity_id стабілізує порядок уже всередині цього типу.
Чому relations завантажуються після pagination?
Після визначення сторінки вже відомо, які саме IDs потрібні. Тому кожен тип може eager load лише свої записи й свої relations замість завантаження зайвих моделей до того, як стане зрозуміло, чи потраплять вони на сторінку.

Для мене цей кейс добре показує, що feed із різних сутностей - це не проблема компонента Vue і не просто SQL UNION. Найважливіше рішення знаходиться на межі між сортуванням, пагінацією та hydration.

Коли ця межа визначена правильно, додавання нового типу контенту вже не вимагає ламати всю стрічку. Потрібно дати йому спільну проєкцію, стабільне місце в ordering, власну hydration-логіку та нормалізований public payload.