lampager/lampager-laravel
Laravel integration for Lampager, adding efficient cursor-based (seek) pagination to Eloquent and the query builder. Paginate large datasets with stable, reversible ordering and a simple API, avoiding offset performance issues in Laravel apps.
lampager/lampager-laravel is the Laravel adapter for Lampager: keyset (cursor) pagination for the query builder and Eloquent, with no OFFSET and no COUNT(*) query. Use it wherever paginate() becomes slow or semantically wrong — infinite scroll, "load more" feeds, JSON APIs, admin tables on large tables.
composer require lampager/lampager-laravel
That is the whole setup. There is no config file to publish, no migrations, no routes; package discovery registers the service provider and the query-builder macro. v0.6.1 is a compatibility-only release — no migration steps, nothing to change in your code.
First use case — replace an offset-paginated feed:
use App\Models\Post;
$result = Post::query()
->where('published', true)
->orderBy('created_at', 'desc')
->orderBy('id', 'desc') // unique tiebreaker — mandatory
->lampager()
->paginate(20);
$result->records; // Collection of models
$result->hasPrevious; // bool — was there a page before this one?
$result->hasNext; // bool — is there another page?
Where to look first, in order:
paginate() signature for your version (the adapter is thin, so the surface area is small).Lampager\Laravel\QueryBuilder and the result class — the only two objects you touch day to day.lampager/lampager docs — the "how does it know the cursor?" magic lives there, not in the Laravel adapter. Everything the wrapper does is derived from the where() and orderBy() calls you put on the query before lampager().The core mental model: you build the query as if you were filtering "everything after the last row I saw", and the package rewrites it to also fetch the previous page (reversing order and flipping comparison operators) when needed.
Feed / infinite scroll (next page only)
The cursor is just the last row's sort values, taken from the request and replayed as conditions:
$query = Post::query()->where('published', true);
if ($request->filled('cursor_id')) {
$query->where('id', '<', (int) $request->input('cursor_id'));
}
$result = $query
->orderBy('id', 'desc')
->lampager()
->paginate(20);
return response()->json([
'data' => PostResource::collection($result->records),
'has_next' => $result->hasNext,
'next_cursor' => optional($result->records->last())->id,
]);
Validate and whitelist every cursor input — an unvalidated cursor parameter is a user-supplied WHERE clause.
API Resource wrapper
The release notes touch the Resource integration tests, and this is the natural way to expose results. Wrap the result once and reuse it:
class PaginatedCollection extends JsonResource
{
public function toArray($request): array
{
return [
'data' => $this->records,
'meta' => [
'has_previous' => $this->hasPrevious,
'has_next' => $this->hasNext,
],
];
}
}
Bidirectional tables (admin / archive browsers)
hasPrevious is not a COUNT. The package answers it by issuing a reversed, LIMIT 1 probe query, so "previous" links work without knowing the page number. Pass a backward cursor the same way you pass a forward one; the wrapper handles flipping.
Compose before you wrap
Scopes, filters, with(), and select() all belong on the underlying Eloquent builder before lampager():
$result = Post::query()
->with('author')
->whereHas('tags', fn ($q) => $q->whereIn('slug', $slugs))
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->lampager()
->paginate(15);
Eager loading still works because the parent rows are fetched in a single keyset query.
Relations
Pagination on a relation reads well and keeps tenancy scoping intact:
$result = $user->posts()->where('draft', false)->orderBy('id', 'desc')->lampager()->paginate(10);
Testing assertions worth writing
count(*) and no offset.pluck('id') intersection is empty) — this catches missing tiebreakers instantly.hasNext is false on the final page and that the final page's row count is < perPage.orderBy('created_at') alone gives you skipped and duplicated rows at page boundaries. Always append a unique tiebreaker (id) — this is the single most common bug with keyset pagination and the adapter cannot detect it for you.total(), no lastPage(), no page numbers. That is the trade-off that buys you the performance. If a UI needs "page 7 of 42", either run a separate cached count() or use offset pagination.orderByRaw, groupBy, having, union, distinct(), and complex joins with duplicated rows are outside that model and will produce wrong cursors rather than an exception. Keep the query simple: filters + whereHas + plain orderBy.NULL values don't behave like id comparisons. Make the sort column NOT NULL with a default, or normalise it before ordering.datetime or a custom cast, round-trip the database value, not the Carbon-formatted one, or you'll skip rows at second/microsecond boundaries.DB::listen() / DB::enableQueryLog() in a test and read the generated SQL. Seeing the reversed probe query is the fastest way to understand why a page boundary misbehaves. If the SQL looks right and results look wrong, the bug is almost always your orderBy set.lampager() works through __call forwarding, but mixing the two styles makes it hard to reason about what the wrapper saw. Pick "build the query first, wrap last" as a convention.0.x package. Pin ^0.6.1 and read the changelog before minor bumps; the changelog is short and honest, which makes this cheap.ResourceTest fix — a CI-only concern unless you had a failing resource-wrapping test of your own. No behavioural changes, no deprecations.paginate() with Laravel's. On the Lampager wrapper it means keyset pagination and returns the Lampager result object; on a plain Eloquent builder it returns LengthAwarePaginator. If you get total() missing errors, you wrapped the wrong object or forgot the lampager() call.How can I help you explore Laravel packages today?