Laravel Cursor Pagination Needs a Total Order
Build stable API feeds with a deterministic sort, a unique tie-breaker, and an index that matches the query.
Changing paginate() to cursorPaginate() is one line of Laravel code. Designing the order that makes the cursor correct is the actual work.
A cursor does not mean “page two.” It means “continue after this ordered position.” That position is only unambiguous when the query has a total order: every row has one stable place relative to every other row. Sorting by a non-unique column such as published_at does not provide that guarantee. If several rows share the same timestamp, the database may return those ties in any order, and a cursor cannot identify which tied row was the last one already returned.
This article targets Laravel 13 and PHP 8.3 or later. The example builds a descending article feed ordered by published_at, then by id as a unique tie-breaker. The same design applies to activity feeds, audit records, transactions, messages, and other APIs where clients move forward or backward through an ordered result set.
The cursor is part of the query contract
Offset pagination asks the database to skip a number of rows. Cursor pagination asks it to find rows before or after the values encoded in a cursor. Laravel’s documentation illustrates the difference with queries shaped like these:
offset: select * from users order by id asc limit 15 offset 15
cursor: select * from users where id > 15 order by id asc limit 15The second shape can use an ordered index to seek from a known value rather than scanning all earlier matches. It also avoids one source of drift in a frequently changing data set. If a new row is inserted ahead of an offset while a client moves between pages, the shifted offset can cause a duplicate. If a row ahead of it is deleted, the client can skip a row.
Cursor pagination has a different limitation: it cannot offer numbered pages because it does not calculate an arbitrary offset or total page map. It gives the client next and previous positions. That is a good contract for feeds and “load more” interfaces, but it is the wrong contract for a report that must jump directly to page 37.
More importantly, the ordered columns must identify one position. Laravel 13 explicitly requires at least one unique ordering column, or a unique combination of ordering columns, and does not support null in those ordered values. Order-by expressions work only when they are aliased and added to the select, and expressions with bindings are not supported. A cursor is not a fix for an incomplete sort definition.
A non-unique sort creates an ambiguous boundary
Suppose an article feed uses this query:
<?php
declare(strict_types=1);
use App\Models\Article;
$articles = Article::query()
->where('status', 'published')
->orderByDesc('published_at')
->cursorPaginate(25);The code looks reasonable, but published_at is not necessarily unique. Imports, scheduled releases, or application code that rounds timestamps can give several articles the same value. If the final row in a response has published_at = 2026-10-28 09:00:00, that timestamp alone does not say which of the tied rows have already appeared.
Adding id as a tie-breaker changes the order from "newest timestamp first" to "newest timestamp first, then highest ID first among equal timestamps." Every article now has one position:
<?php
declare(strict_types=1);
use App\Models\Article;
$articles = Article::query()
->where('status', 'published')
->orderByDesc('published_at')
->orderByDesc('id')
->cursorPaginate(perPage: 25);Laravel can encode both ordered values in the cursor and construct the comparison needed for the next request. The id is not a second business sort chosen by the user. It is the deterministic rule for ties.
The direction must agree across the pair. A descending timestamp with a descending ID is easy to explain and index. Mixed directions can be valid, but they make the comparison and index design harder to reason about. Use them only when the product order requires them.
This example also assumes published_at is non-null for rows whose status is published. Enforce that invariant in application logic and data cleanup before adopting this cursor. A nullable ordered column is outside Laravel's documented cursor-pagination constraints. Filtering with whereNotNull('published_at') may make a legacy query operable, but it also hides invalid published rows. Fixing the state model is usually better.
Make the index follow the filter and order
Correct ordering is not enough for an efficient feed. The database needs an index that fits the query shape. For a feed filtered by status and ordered by timestamp plus ID, start with a composite index in that sequence:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('articles', function (Blueprint $table): void {
$table->index(
['status', 'published_at', 'id'],
'articles_status_published_at_id_index',
);
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table): void {
$table->dropIndex('articles_status_published_at_id_index');
});
}
};status appears first because the query constrains it to one value. The remaining columns match the ordered cursor position. The exact execution plan still depends on the database engine, data distribution, selected columns, and other predicates. Do not treat a plausible index definition as proof. Run EXPLAIN for the generated first-page and next-page queries on the production database engine with representative data.
A wide feed can also force extra table lookups even when the index finds row positions efficiently. Select only fields the API needs, then measure. A covering index can help some workloads, but adding every response column to an index increases storage and write cost. Index design is a workload decision, not a decoration attached to cursorPaginate().
Schema changes need their own operational plan. Creating an index on a large, busy table can consume I/O or hold locks depending on the database and operation used. Inspect the SQL Laravel generates, test the migration on a representative copy, and use the database’s appropriate online or concurrent index procedure when a normal migration is unsafe.
Keep filters stable across cursor requests
A cursor belongs to one ordered query. If the client requests the first batch with status=published&author=42, then sends the returned cursor with author=91, it is combining a position from one result set with another result set. Laravel can decode the cursor, but the request no longer has coherent pagination semantics.
Validate filters, construct the query in one place, and preserve them in generated links:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Resources\ArticleResource;
use App\Models\Article;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class ArticleFeedController
{
public function __invoke(Request $request): AnonymousResourceCollection
{
$filters = $request->validate([
'author_id' => ['sometimes', 'integer', 'exists:users,id'],
'per_page' => ['sometimes', 'integer', 'between:1,100'],
]);
$articles = Article::query()
->select(['id', 'author_id', 'title', 'published_at'])
->where('status', 'published')
->when(
isset($filters['author_id']),
fn ($query) => $query->where('author_id', $filters['author_id']),
)
->orderByDesc('published_at')
->orderByDesc('id')
->cursorPaginate(
perPage: (int) ($filters['per_page'] ?? 25),
cursorName: 'cursor',
)
->withQueryString();
return ArticleResource::collection($articles);
}
}The controller hard-codes one order rather than accepting arbitrary column names or directions from the request. That keeps the public contract narrow and prevents untrusted input from becoming an ORDER BY identifier. If the API needs several sort modes, map each public mode to an explicit query and matching index. Keep published_at and id in the select(), because Laravel reads the next cursor's values from the returned rows.
Get Udit Rawat’s stories in your inbox
Join Medium for free to get updates from this writer.
withQueryString() appends current query parameters to paginator links, so author_id and per_page remain present in the next URL. It does not cryptographically bind those filters into the cursor. A client can still alter them. If changing filters while retaining a cursor must be rejected, include a normalized filter fingerprint in a separately signed request token or store pagination state server-side. That extra mechanism is often unnecessary for a public feed, but the API contract should state that changing filters requires restarting without a cursor.
Laravel resource collections accept paginator instances and retain pagination information in the JSON response. With a cursor paginator, clients should follow the returned links rather than parsing or constructing cursor strings. Treat the cursor as opaque. Its internal encoding is framework state, not a domain identifier or a stable integration format to reproduce in another service.
Test boundaries, not the encoded string
A useful test creates tied timestamps and proves that traversing the feed returns each row once in the intended order. It should not assert the literal cursor value:
<?php
declare(strict_types=1);
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('walks tied publication times in deterministic order', function (): void {
$publishedAt = now()->startOfSecond();
$articles = Article::factory()
->count(5)
->sequence(fn ($sequence) => [
'status' => 'published',
'published_at' => $publishedAt,
'title' => 'Article '.($sequence->index + 1),
])
->create();
$expectedIds = $articles->pluck('id')->sortDesc()->values()->all();
$first = $this->getJson('/api/articles?per_page=2')
->assertOk()
->json();
$second = $this->getJson($first['links']['next'])
->assertOk()
->json();
$third = $this->getJson($second['links']['next'])
->assertOk()
->json();
$actualIds = collect([
...$first['data'],
...$second['data'],
...$third['data'],
])->pluck('id')->all();
expect($actualIds)
->toBe($expectedIds)
->and(array_unique($actualIds))
->toHaveCount(5)
->and($third['links']['next'])
->toBeNull();
});The test checks the observable contract: five tied records appear in descending ID order, with no duplicate, no omission, and no next link after the last batch. It deliberately follows the link emitted by Laravel. Hard-coding the encoded cursor would couple the test to an implementation detail without proving the feed is correct.
Add a separate test for each supported filter and sort mode. Also test an insertion between requests if the product promises particular behavior for live feeds. Cursor pagination gives a stable continuation relative to ordered values, but it is not a snapshot transaction spanning several HTTP requests. A row inserted ahead of the client’s current position will normally not appear during forward continuation. A row inserted behind that position may appear later. If the product requires a frozen export, capture a high-water mark or materialize the result set instead of presenting an evolving feed as a snapshot.
Decide what “stable” means for mutable sort keys
published_at can change. If an article is republished while a client is traversing the feed, the row moves to a new position. No paginator can make an evolving order behave exactly like a fixed snapshot without additional state.
Choose one of three contracts:
- Accept live-feed behavior. New and moved items appear according to their current order when the client refreshes from the beginning.
- Add a high-water mark. The first request records an upper boundary, and later requests include it so newer items stay outside that traversal.
- Build a snapshot or export job. Store the selected identifiers or query result version when exact repeatability matters.
The first option is appropriate for many timelines. The second is useful when a user must finish reading a bounded set. The third belongs to reports, billing evidence, and exports where the same traversal must reproduce the same membership. Cursor pagination solves seeking and ordering. It does not choose the product’s consistency model.
A practical adoption checklist
Use cursor pagination when the interface moves sequentially, the data set is large or frequently written, and next or previous navigation is enough. Keep offset pagination when numbered pages, arbitrary jumps, or a total page count are part of the requirement.
Before shipping a cursor-based Laravel endpoint, verify:
- The query has an explicit order.
- The ordered columns form a unique combination.
- No ordered value can be
nullin the result set. - A deterministic tie-breaker, usually the primary key, follows the business sort.
- The filter and order have a supporting index verified with
EXPLAIN. - Supported filters and sort modes are allow-listed rather than passed directly into SQL identifiers.
- Pagination links preserve filters, and API documentation tells clients to treat cursors as opaque.
- Tests traverse tied values and assert order, uniqueness, omissions, and the final boundary.
- The product has chosen live-feed, high-water-mark, or snapshot semantics for mutable data.
cursorPaginate() is the final method call, not the design. Define one unambiguous order, support it with the right index, and test the boundaries clients will actually cross.
Sources
- Laravel 13 pagination documentation
- Laravel 13 Eloquent API resources documentation
- Laravel 13 migrations documentation
- Laravel 13 release notes
Udit Rawat is a Senior Software Engineer with more than 10 years of experience and co-founder of Moosky AI.









