Після появи 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
Визначає порядок між різними типами, якщо час публікації однаковий.
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 може однозначно описати останній елемент сторінки.
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, }; }}Cursor pagination працює вже поверх UNION
$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-моделі.
Побудувати легкі проєкції
Кожен тип повертає тільки feed_type, entity_id, published_at і type_order.
Зробити UNION ALL
Три джерела стають однією SQL-послідовністю з єдиним набором колонок.
Відсортувати й відрізати сторінку
Cursor pagination працює до завантаження важких relations, тому ми вже знаємо точні IDs поточної сторінки.
Згрупувати IDs за типом
Posts, News і Tips отримують власні списки ідентифікаторів тільки для цієї сторінки.
Hydrate кожен тип окремо
Кожен Eloquent query завантажує тільки свої relations і потім результати повертаються в початковий feed order.
$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,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, різні картки
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// publishedAtFrontend отримує однакові базові поля, але кожен тип контенту зберігає власну специфіку.
Тип | Що залишається специфічним |
|---|---|
Post | Category, Series, reading time, views, comments і reading progress. |
News | Власний type, label, tone та public URL. |
Tip | Type, topic, tags та власна компактна картка. |
Тести для межі сторінки важливіші за happy path
$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?
Чому UNION ALL, а не UNION?
Навіщо type_order, якщо є entity_id?
Чому relations завантажуються після pagination?
Для мене цей кейс добре показує, що feed із різних сутностей - це не проблема компонента Vue і не просто SQL UNION. Найважливіше рішення знаходиться на межі між сортуванням, пагінацією та hydration.
Коли ця межа визначена правильно, додавання нового типу контенту вже не вимагає ламати всю стрічку. Потрібно дати йому спільну проєкцію, стабільне місце в ordering, власну hydration-логіку та нормалізований public payload.



