Building infinite scroll feeds for blogs, timelines, and activity feeds.
Use when building feeds, timelines, blogs, or any content list with "load more" or infinite scroll.
namespace App\Data;
use AdroSoftware\DataProxy\DataProxy;
use AdroSoftware\DataProxy\Requirements;
use AdroSoftware\DataProxy\Shape;
use AdroSoftware\DataProxy\Result;
use App\Models\Post;
class FeedData
{
public static function fetch(
int $limit = 20,
?int $afterId = null
): Result {
return DataProxy::make()->fetch(
Requirements::make()
->query('items', Post::class, self::shape($limit, $afterId))
->compute('hasMore', fn($data) => $data['items']->count() === $limit, ['items'])
->compute('nextCursor', fn($data) => $data['items']->last()?->id, ['items'])
);
}
private static function shape(int $limit, ?int $afterId): Shape
{
return Shape::make()
->select('id', 'title', 'excerpt', 'created_at')
->with('author', Shape::make()->select('id', 'name', 'avatar'))
->when($afterId, fn($s) => $s->where('id', '<', $afterId))
->latest()
->limit($limit);
}
}
public function feed(Request $request)
{
$data = FeedData::fetch(
limit: $request->input('limit', 20),
afterId: $request->input('after_id')
);
return response()->json([
'items' => $data->items->toArray(),
'hasMore' => $data->hasMore,
'nextCursor' => $data->nextCursor,
]);
}
On button click or scroll:
?after_id={nextCursor}hasMore is falseAlways use consistent ordering (e.g., id DESC) to prevent duplicates when new items are added.
Use multiple scope() calls for composable feed filtering. Scopes accumulate and are applied in order:
private static function shape(
int $limit,
?int $afterId,
?User $viewer,
array $excludeIds = []
): Shape {
return Shape::make()
->select('id', 'title', 'excerpt', 'created_at')
->with('author', Shape::make()->select('id', 'name', 'avatar'))
// Base scope - aggregates
->scope(fn($query) => $query->withCount('likes'))
// Visibility scope - filter based on viewer permissions
->scope(fn($query) => $query->listableFor($viewer))
// Cursor-based pagination scope
->when($afterId, fn($s) => $s->scope(
fn($query) => $query->where('id', '<', $afterId)
))
// Exclusion scope - hide already-seen items
->when(!empty($excludeIds), fn($s) => $s->scope(
fn($query) => $query->whereNotIn('id', $excludeIds)
))
->orderByDesc('id')
->limit($limit);
}
This pattern keeps each concern (aggregates, visibility, pagination, exclusions) in its own scope, making the code easier to understand and maintain.
class TimelineData
{
public static function fetch(int $userId, int $limit = 20, ?int $afterId = null): Result
{
return DataProxy::make()->fetch(
Requirements::make()
->query('activities', Activity::class, self::shape($userId, $limit, $afterId))
->compute('hasMore', fn($data) => $data['activities']->count() === $limit, ['activities'])
->compute('nextCursor', fn($data) => $data['activities']->last()?->id, ['activities'])
);
}
private static function shape(int $userId, int $limit, ?int $afterId): Shape
{
return Shape::make()
->select('id', 'type', 'data', 'created_at')
->with('subject')
->where('user_id', $userId)
->when($afterId, fn($s) => $s->where('id', '<', $afterId))
->orderByDesc('id')
->limit($limit);
}
}
class FeedComponent extends Component
{
public int $cursor = 0;
public bool $hasMore = true;
public Collection $items;
public function mount()
{
$this->items = collect();
$this->loadMore();
}
public function loadMore()
{
$data = FeedData::fetch(
limit: 20,
afterId: $this->cursor ?: null
);
$this->items = $this->items->concat($data->items);
$this->hasMore = $data->hasMore;
$this->cursor = $data->nextCursor;
}
}
Use hydrate() to batch-load additional data for feed items after the query executes. Ideal for like counts, user interactions, or external data.
private static function shape(int $limit, ?int $afterId): Shape
{
return Shape::make()
->select('id', 'title', 'excerpt', 'created_at')
->with('author', Shape::make()->select('id', 'name', 'avatar'))
->when($afterId, fn($s) => $s->where('id', '<', $afterId))
->latest()
->limit($limit)
->hydrate(function ($items, $resolved) {
// Batch load like counts
$postIds = $items->pluck('id');
$likeCounts = Like::whereIn('post_id', $postIds)
->groupBy('post_id')
->selectRaw('post_id, count(*) as count')
->pluck('count', 'post_id');
$items->each(fn ($post) => $post->like_count = $likeCounts[$post->id] ?? 0);
});
}
->hydrate(function ($items, $resolved) {
$currentUserId = auth()->id();
$postIds = $items->pluck('id');
// Batch check if current user liked each post
$userLikes = Like::where('user_id', $currentUserId)
->whereIn('post_id', $postIds)
->pluck('post_id')
->flip();
// Batch check bookmarks
$userBookmarks = Bookmark::where('user_id', $currentUserId)
->whereIn('post_id', $postIds)
->pluck('post_id')
->flip();
$items->each(function ($post) use ($userLikes, $userBookmarks) {
$post->is_liked = isset($userLikes[$post->id]);
$post->is_bookmarked = isset($userBookmarks[$post->id]);
});
})
->hydrate(function ($items, $resolved) {
// Batch fetch preview images from external service
$urls = $items->pluck('external_url')->filter()->toArray();
$previews = LinkPreviewService::batchFetch($urls);
$items->each(function ($post) use ($previews) {
if ($post->external_url) {
$post->preview_image = $previews[$post->external_url] ?? null;
}
});
})