0% прочитано

Laravel Feed Architecture: Mixing Posts, News and Tips Without Breaking Pagination

How ICanUp mixes Posts, News, and Tips into one chronological Laravel feed using UNION ALL, deterministic ordering, cursor pagination, and a second hydration phase for the full Eloquent models.

24 вересня 2026 р. 10 хв читанняLaravel

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.

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{

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.

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,        };    }}
In ICanUp, equal published_at values use a fixed type order: Post = 30, News = 20, Tip = 10. entity_id is the final tie-breaker. The exact numbers are less important than having an explicit and stable rule.

Cursor Pagination Runs Over the 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',    ])

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.

  1. Build Lightweight Projections

    Each type returns only feed_type, entity_id, published_at, and type_order.

  2. Combine Them With UNION ALL

    The three sources become one SQL sequence with the same set of columns.

  3. Order and Cut the Page

    Cursor pagination runs before expensive relations are loaded, so the exact IDs for the current page are already known.

  4. Group IDs by Type

    Posts, News, and Tips receive separate ID lists containing only the current page.

  5. Hydrate Each Type Separately

    Each Eloquent query loads its own relations and the models are then restored to the original 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),    ])

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

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

The 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.

The category filter in ICanUp has explicit semantics: when a category is selected, the unified feed returns only Posts from that category. News and Tips do not have a Post category, so they are not mixed into the filtered list.

Test the Page Boundary, Not Only the 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([

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?
A correct page boundary requires the global ordering across all three sources first. If each source has already cut its own page, some records that belong to the shared page may never reach the merge step.
Why UNION ALL instead of UNION?
Feed rows have explicit identity through feed_type and entity_id, so SQL deduplication between sources is unnecessary. UNION ALL avoids extra duplicate-removal work.
Why is type_order needed if entity_id exists?
entity_id values from different tables do not define cross-type ordering. type_order decides which type wins an equal published_at first, and entity_id then stabilizes ordering within that type.
Why load relations after pagination?
Once the page is selected, the exact IDs are known. Each type can eager load only those records and the relations it actually needs instead of loading models that may never appear on the page.

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.