php
46 lines · 8 steps
Building a cursor-paginated feed in Laravel
A JSON API endpoint that validates input, builds an efficient query, and returns cursor-paginated posts with metadata.
Explained by
highlit
1<?php
2
3namespace App\Http\Controllers\Api;
4
5use App\Http\Controllers\Controller;
6use App\Http\Resources\PostResource;
7use App\Models\Post;
8use Illuminate\Http\Request;
9
10class FeedController extends Controller
11{
12 public function index(Request $request)
13 {
14 $validated = $request->validate([
15 'cursor' => ['nullable', 'string'],
16 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
17 'tag' => ['nullable', 'string', 'exists:tags,slug'],
18 ]);
19
20 $perPage = $validated['per_page'] ?? 15;
21
22 $posts = Post::query()
23 ->select(['id', 'user_id', 'title', 'slug', 'excerpt', 'published_at'])
24 ->with('author:id,name,avatar_url')
25 ->withCount('comments')
26 ->whereNotNull('published_at')
27 ->where('published_at', '<=', now())
28 ->when($validated['tag'] ?? null, fn ($query, $tag) => $query->whereHas(
29 'tags',
30 fn ($q) => $q->where('slug', $tag)
31 ))
32 ->orderByDesc('published_at')
33 ->orderByDesc('id')
34 ->cursorPaginate($perPage)
35 ->withQueryString();
36
37 return PostResource::collection($posts)->additional([
38 'meta' => [
39 'next_cursor' => $posts->nextCursor()?->encode(),
40 'prev_cursor' => $posts->previousCursor()?->encode(),
41 'has_more' => $posts->hasMorePages(),
42 'per_page' => $posts->perPage(),
43 ],
44 ]);
45 }
46}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Cursor pagination scales better than offset pagination for large, ordered feeds because it avoids expensive OFFSET scans.
- 2Selecting explicit columns and eager-loading relations keeps a feed query lean and dodges N+1 problems.
- 3API Resources plus additional() cleanly separate serialized records from pagination metadata in the response.
Related explainers
go
func (h *WebhookHandler) HandleBatch(c *gin.Context) { var payload BatchWebhookPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
Handling batched webhooks in Gin
webhooks
signature-verification
job-queue
Intermediate
7 steps
java
@Controller @RequestMapping("/employees") public class EmployeeController {
Customizing form binding in a Spring MVC controller
data-binding
validation
type-conversion
Intermediate
9 steps
php
<?php namespace App\Services;
Caching per-tenant settings in Laravel
caching
multi-tenancy
dependency-injection
Intermediate
7 steps
ruby
require "phonelib" class PhoneNumber class InvalidNumber < StandardError; end
Wrapping phone parsing in a Ruby value object
value-object
memoization
validation
Intermediate
7 steps
typescript
import { Component } from '@angular/core'; import { NgForm } from '@angular/forms'; interface SignupModel {
How template-driven forms validate in Angular
forms
two-way-binding
validation
Intermediate
9 steps
php
<?php namespace App\Http\Middleware;
Idempotency keys in Laravel middleware
idempotency
middleware
caching
Advanced
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-cursor-paginated-feed-in-laravel-explained-php-4e09/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.