php 98 lines · 3 tabs

Cursor Pagination for Eloquent Queries Exposed as a JSON API in Laravel

Shared by codesnips Jul 2026
3 tabs
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $request)
    {
        $perPage = (int) $request->integer('per_page', 20);
        $perPage = max(1, min($perPage, 100));

        $posts = Post::query()
            ->where('published', true)
            ->with('author:id,name')
            ->latest('created_at')
            ->orderBy('id', 'desc') // deterministic tie-breaker
            ->cursorPaginate($perPage)
            ->withQueryString();

        return PostResource::collection($posts);
    }
}
3 files · php Explain with highlit

Cursor (keyset) pagination avoids the classic performance trap of OFFSET-based paging: as the offset grows, the database still has to scan and discard every skipped row, so page 5000 is far slower than page 1. Cursor pagination instead remembers the last row seen and asks the database for rows after that position using an indexed WHERE clause, giving stable, constant-time paging even over very large tables. Laravel exposes this through Model::cursorPaginate(), and these three files wire it into a clean JSON API.

In PostController, the index action reads a bounded per_page value and calls cursorPaginate() on a query that is explicitly ordered by created_at and id. The ordering matters: cursor pagination encodes the values of the ordered columns into the opaque cursor, and a tie-breaker column (id) is required so rows with identical timestamps still have a total, deterministic order. Without a unique tie-breaker the cursor could skip or repeat rows. The latest/orderBy pair produces exactly that stable ordering.

The raw CursorPaginator is not returned directly. Instead it is wrapped in PostResource::collection(...), so the controller controls the response shape rather than leaking database columns. PostResource in the second tab maps each model to a trimmed JSON payload and formats timestamps as ISO-8601, which keeps the API contract independent of the schema.

The third tab, PostCollection, overrides toArray and with to build a JSON:API-flavored envelope. It reads nextCursor() and previousCursor() from the underlying paginator and calls ->encode() to turn each cursor object into the URL-safe string clients echo back via the ?cursor= query parameter. Those strings are placed under a meta.cursors block alongside next/prev links generated with previousPageUrl() and nextPageUrl().

A key trade-off is that cursor pagination cannot jump to an arbitrary page number and cannot report a total count cheaply, so it suits infinite-scroll and feed-style endpoints rather than numbered page controls. The hasMorePages() check lets clients stop requesting once the feed is exhausted. Because the cursor is opaque and derived from real column values, the ordering columns must be covered by an index for the approach to pay off.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor Pagination for Eloquent Queries Exposed as a JSON API in Laravel — share card
Link copied