Pagination types
The closure receives $offset, $limit, and $page (computed for you), so you can use whichever query style fits your data source.
Offset with skip and take
The simplest approach and the right default for database tables with a stable sort order:
use MrPunyapal\FilamentInfiniteSelect\InfiniteSelect;
InfiniteSelect::make('user_id')
->getPaginatedOptionsUsing(function (int $offset, int $limit, ?string $search) {
$query = User::query()
->orderBy('name')
->orderBy('id');
if ($search) {
$query->where('name', 'like', "%{$search}%");
}
return [
'options' => $query->skip($offset)->take($limit)->pluck('name', 'id')->all(),
'hasMore' => ($offset + $limit) < User::count(),
];
});
Two rules keep offset paging correct at scale: always add a unique tiebreaker column (orderBy('id')) next to the sortable column, and index both columns together.
Laravel paginate with the injected page number
The closure receives $page as a 1-based page number computed from the offset. That means you can hand it straight to paginate():
InfiniteSelect::make('user_id')
->getPaginatedOptionsUsing(function (int $limit, ?string $search, int $page) {
$query = Product::query()->orderBy('title');
if ($search) {
$query->where('title', 'like', "%{$search}%");
}
$paginator = $query->paginate($limit, ['*'], 'page', $page);
return [
'options' => $paginator->pluck('title', 'id')->all(),
'hasMore' => $paginator->hasMorePages(),
];
});
Note that you only need to inject the parameters your closure actually declares.
Cursor pagination
Cursor pagination avoids scanning skipped rows on very large tables. Because cursors are tied to the previous page, rebuild the starting cursor from the offset first:
use Illuminate\Pagination\Cursor;
InfiniteSelect::make('user_id')
->getPaginatedOptionsUsing(function (int $offset, int $limit, ?string $search) {
$query = User::query()
->orderBy('name')
->orderBy('id');
if ($search) {
$query->where('name', 'like', "%{$search}%");
}
$cursor = null;
if ($offset > 0) {
$last = (clone $query)->skip($offset - 1)->first();
if (! $last) {
return ['options' => [], 'hasMore' => false];
}
$cursor = new Cursor(['name' => $last->name, 'id' => $last->id]);
}
$rows = (clone $query)->cursorPaginate(perPage: $limit, cursor: $cursor);
return [
'options' => $rows->pluck('name', 'id')->all(),
'hasMore' => $rows->hasMorePages(),
];
});
The Cursor keys must match the order columns exactly, including the unique tiebreaker.
Fetching one extra row instead of counting
For any style, detecting the last page with a single extra row is cheaper than a separate count query:
$rows = $query->skip($offset)->take($limit + 1)->get();
return [
'options' => $rows->take($limit)->pluck('name', 'id')->all(),
'hasMore' => $rows->count() > $limit,
];
Which one should I use?
| Situation | Style |
|---|---|
| Database table up to tens of thousands of rows | Offset with skip and take |
| You already have a paginated repository or API returning pages | Laravel paginate with $page |
| Very large tables where OFFSET scans get slow | Cursor pagination |
| External API that only supports page numbers | paginate style |
| External API that returns a next-page token | Store the token from each response inside your closure scope or cache, keyed by search term, and request it when $offset matches the expected position |
Remote APIs
The closure can call anything that returns an array of value-label pairs:
InfiniteSelect::make('city_id')
->getPaginatedOptionsUsing(function (int $offset, int $limit, ?string $search) {
$response = Http::get('https://api.example.com/cities', [
'q' => $search,
'offset' => $offset,
'limit' => $limit,
])->json();
return [
'options' => collect($response['data'])
->mapWithKeys(fn (array $city) => [$city['id'] => $city['name']])
->all(),
'hasMore' => $response['meta']['total'] > ($offset + $limit),
];
});