After News and Tips appeared in ICanUp, I wanted them in the same public feed that already contained Posts. From the UI perspective the requirement sounds simple: take three lists, mix them by date, and render them.
Pagination changes the problem. If each content type is paginated separately and merged in PHP, there is no single page boundary. If ordering uses only published_at, equal timestamps have no deterministic order. And if the first query hydrates every full Eloquent model with every relation, the feed query becomes unnecessarily expensive.
Start With One Minimal Projection
What the Shared Feed Actually Needs
The ordering query does not need titles, Media, categories, or tags. It only needs the fields that define identity and position.
feed_type
Identifies the source entity: post, news, or tip.
entity_id
Provides a stable identifier inside the corresponding content type.
published_at
Creates one shared timeline across all three tables.
type_order
Defines deterministic ordering between types when publication timestamps are equal.
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{Each entity keeps its own canonical visibility rules. Posts use PublishedOnly and PublicPostFilter, News use viewableInLocale(), and Tips use publiclyVisible().
This matters because a unified feed should not create another slightly different definition of public content. The projection only converts already valid records into a shared shape.
Why published_at Is Not Enough
Unstable: ORDER BY published_at DESC.
Two or more records can have the same timestamp. The database does not have to return those rows in the same internal order between requests, which makes a weak page boundary for cursor pagination.
Deterministic: published_at DESC, type_order DESC, entity_id DESC.
Every row has a complete position on the shared timeline, so the cursor can describe the last item of the page unambiguously.
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 Runs Over the 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', ])This was the key difference for me between three lists and one feed. The cursor is not created separately for Posts, News, and Tips. It runs over the result of the UNION ALL, which means one ordered sequence.
A page of 12 items can therefore contain any combination of content types, while the next request continues after the last position of that shared sequence.
Stable pagination is not only about knowing what to show. It requires an unambiguous answer to the question: which item comes immediately after this one?
Do Not Push Full Models Through the UNION
Two Phases Instead of One Giant Query
UnifiedFeedService first determines the page composition and order, then hydrates the full Eloquent models separately.
Build Lightweight Projections
Each type returns only feed_type, entity_id, published_at, and type_order.
Combine Them With UNION ALL
The three sources become one SQL sequence with the same set of columns.
Order and Cut the Page
Cursor pagination runs before expensive relations are loaded, so the exact IDs for the current page are already known.
Group IDs by Type
Posts, News, and Tips receive separate ID lists containing only the current page.
Hydrate Each Type Separately
Each Eloquent query loads its own relations and the models are then restored to the original 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), ])For Posts, ICanUp loads localized translations, category, cover Media, the active Series, and the current reader state. News load their translations and imageMedia. Tips load translations, topic, tags, and imageMedia.
The feed does not try to make three different models identical at the Eloquent level. Only the page ordering is shared. Each type keeps its own hydration requirements.
One API Contract, Different Cards
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// publishedAtThe frontend receives the same base fields, while every content type keeps its own specific data.
Type | Type-specific data |
|---|---|
Post | Category, Series, reading time, views, comments, and reading progress. |
News | Its own type, label, tone, and public URL. |
Tip | Type, topic, tags, and its own compact card. |
Test the Page Boundary, Not Only the Happy Path
$firstPage = $service->paginate( locale: 'uk', perPage: 2,); expect( $firstPage ->getCollection() ->map( fn ($item): array => [ $item->type->value, $item->entity->id, ] ) ->all(),)->toBe([The integration tests cover more than the Post -> News -> Tip happy path. They verify crossing the cursor boundary, equal published_at values, visibility rules, category filtering, the normalized resource contract, and zero additional SQL queries while serializing an already hydrated page.
That final assertion is especially useful against N+1 regressions: after paginate(), serializing feed items must not start lazy-loading relations one by one.
Unified Feed in Short
A few decisions that mattered most in this implementation.
Why not merge collections in PHP and paginate afterward?
Why UNION ALL instead of UNION?
Why is type_order needed if entity_id exists?
Why load relations after pagination?
For me, this case is a good reminder that a feed built from different entities is not mainly a Vue component problem and not simply a SQL UNION problem. The important design decision sits at the boundary between ordering, pagination, and hydration.
Once that boundary is clear, adding another content type does not require redesigning the whole feed. It needs a shared projection, a stable place in the ordering, its own hydration logic, and a normalized public payload.



