Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Infinite Select Laravel Package

mrpunyapal/filament-infinite-select

Filament Infinite Select adds a searchable, paginated select field for Laravel Filament forms and tables, letting users search large datasets asynchronously without loading every option. Improve performance and UX with infinite scrolling, customizable queries, and easy integration.

View on GitHub
Deep Wiki
Context7

Getting Started

Requirements: PHP 8.x, Laravel 10–12, and a Filament v4 panel. This is a v4-only plugin; there is no stated support matrix beyond that, so check the package's Filament constraint in composer.json against your panel before installing.

Install and publish the asset:

composer require mrpunyapal/filament-infinite-select
php artisan filament:assets

The service provider registers the Alpine component; there is nothing to add to a panel provider. The filament:assets step is not a one-time install chore. Add it to every deploy that can change package versions, because the component loads a published JS file from public/js/mrpunyapal/filament-infinite-select/... and a 404 there fails silently.

Where to look first:

  1. The field class and the published Alpine component, which together are the whole package. It is small enough to read in one sitting.
  2. playground/ for a runnable reference of the closure contract and scroll behavior. Use it to confirm the package works on your machine, not as a substitute for testing against your own panel.
  3. Your own code: pick the one field that is measurably broken today.

First use case: a single picker with hundreds to low thousands of options where the native Select hydrates everything up front, or where searchable() forces users to know the exact name. Tenant, user, customer, product, or taxonomy pickers are the classic fits. Replace one field behind a feature flag and keep the old implementation one line away:

use MrPunyapal\FilamentInfiniteSelect\InfiniteSelect;

InfiniteSelect::make('user_id')
    ->label('User')
    ->getPaginatedOptionsUsing(function (int $offset, int $limit, ?string $search) {
        $query = User::query()->orderBy('name');

        if ($search) {
            $query->where('name', 'like', "{$search}%");
        }

        $rows = $query->skip($offset)->take($limit + 1)->get();

        return [
            'options' => $rows->take($limit)->pluck('name', 'id')->all(),
            'hasMore' => $rows->count() > $limit,
        ];
    })
    ->getOptionLabelUsing(fn ($value) => User::find($value)?->name);

Because the component subclasses native Select, reverting is a one-line change back to Select::make()->searchable(). Treat the switch as a substitution, not a rewrite.

Implementation Patterns

The closure is the whole integration. It receives $offset, $limit, and $search by name and must return ['options' => [value => label], 'hasMore' => bool]. Collections are converted automatically, so pluck() output is fine. Two rules matter more than anything else in this package:

  • Apply $search on every page, not just offset 0. Forgetting this produces a list that filters the first screen and then silently reverts to unfiltered rows as the user scrolls.
  • Use the limit + 1 pattern rather than a separate count(). The first documented example in the README does a count() per page, which doubles round-trips; the take($limit + 1) version above avoids that entirely.

Extract the closure into a reusable provider. Copy-pasted closures are where hasMore bugs and missing search clauses breed. One tested class per entity enforces scoping, ordering, and the +1 rule in a single place:

namespace App\Support\Options;

class UserOptions
{
    public static function paginate(int $offset, int $limit, ?string $search): array
    {
        $offset = min($offset, 500); // hard page cap against runaway scrolling

        $query = User::query()
            ->select(['id', 'name'])
            ->orderBy('name')
            ->when(auth()->user()?->cannot('viewAny', User::class), fn ($q) => $q->whereRaw('1 = 0'));

        if (filled($search) && mb_strlen($search) >= 2) {
            $query->where('name', 'like', "{$search}%");
        }

        $rows = $query->skip($offset)->take($limit + 1)->get();

        return [
            'options' => $rows->take($limit)->pluck('name', 'id')->all(),
            'hasMore' => $rows->count() > $limit,
        ];
    }
}
InfiniteSelect::make('user_id')
    ->getPaginatedOptionsUsing([UserOptions::class, 'paginate'])
    ->getOptionLabelUsing(fn ($value) => User::find($value)?->name)
    ->perPage(15)
    ->preloadFirstPage()
    ->searchDebounce(300)
    ->scrollThreshold(50);

Label resolvers are release-blocking, not polish. Loaded pages need not contain a previously saved value, so without a resolver edit forms render raw IDs, which is a visible data-quality bug:

// Single select
->getOptionLabelUsing(fn ($value) => User::find($value)?->name)

// Multi-select
->getOptionLabelsUsing(fn (array $values) => User::whereIn('id', $values)->pluck('name', 'id')->all())

Add these to every field the day you add the field, including ones you think only show current data.

Scope by permission inside the closure. The closure is your authorization boundary. A picker that leaks options a user cannot act on is a data-exposure path, not a UX tweak. Cheapest enforcement is a where clause or whereRaw('1 = 0') in the provider, not a policy check in the Blade layer.

Tune the four knobs deliberately. perPage() (default 15), searchDebounce() (default 300ms), scrollThreshold() (default 50px), and preloadFirstPage() are the entire tuning surface:

  • Lower perPage on slow closures; each page is a Livewire round-trip.
  • Raise searchDebounce on expensive providers so typing does not fire a query per keystroke.
  • preloadFirstPage() makes edit forms feel instant but adds one query on every form mount.
  • scrollThreshold is the preload distance; larger values smooth scrolling at the cost of earlier (and more) requests.

Wrap usage in a thin internal adapter. A App\Filament\Fields\EntityPicker factory that hides InfiniteSelect behind one method lets you swap packages, add a default provider, or enforce a house closure pattern without touching field definitions. Given a bus factor of one upstream, this is the cheapest insurance available.

Test the provider, smoke the interaction. Unit-test UserOptions::paginate() for: search applied on a non-zero offset, hasMore false on the final page, hasMore true on a full page, and scoping. The async behavior itself is Alpine plus Livewire, so verify it in a browser: open the dropdown, confirm the request fires, confirm results paginate, and confirm the saved value renders a label on edit.

Pilot sequence: one field, feature-flagged, in a dev panel first. Instrument closure invocation count and query timing server-side. Review before extending to the next field.

Gotchas and Tips

Asset 404 is a silent failure. The select renders, the spinner never resolves, and nothing is thrown. This is the single most likely operational surprise. Add a deploy smoke check that requests the published asset path in the deployed environment, and verify it works if your /js route goes through a CDN or a non-default public root. That path is the usual culprit.

A throwing closure hangs the spinner forever. There is no user-facing error surface. Wrap the provider body in a try/catch, log the exception with the offset and search term, and return ['options' => [], 'hasMore' => false] so the field degrades instead of spinning.

Returning hasMore: true unconditionally is a request amplifier. A user scrolling a 500k-row table can generate 5–15 page requests per session legitimately; a runaway hasMore makes it unbounded. Enforce a page cap server-side ($offset upper bound) and a minimum search length before the closure does a LIKE.

Unindexed %term% search turns a payload win into a query loss. Leading-wildcard LIKE cannot use a B-tree index. Prefer prefix matching (term%) where UX allows, and confirm the searched column is indexed before enabling the field on that entity.

Offset pagination drifts. Rows inserted or deleted mid-session cause duplicated or skipped options between pages, and a stale label on a saved record. Acceptable for admin tooling, disqualifying for audit-sensitive surfaces. If drift matters, order by a stable immutable key (typically id) rather than a mutable label, and say so in the field's comment.

Missing resolvers show raw IDs. The symptom users report is "the dropdown shows numbers." Every ticket in this package's likely queue has a known cause: IDs (missing getOptionLabelUsing), endless spinner (throwing closure or stuck hasMore), options stopped loading after deploy (unpublished asset), saved value disappeared (offset drift or the record was deleted).

There is no total result count in the UI. limit + 1 tells you whether more exist, not how many. Do not design copy or empty states that promise "showing 15 of 12,431."

Not supported, confirm before promising: grouped options, create-option/tagging, cascading selects, and rich option rendering. Also verify behavior inside modals, repeaters, and nested relation managers, where selector scope and asset loading differ from a plain form.

Relationship-backed fields: the description claims Eloquent relationship support, but the reliable path is to write the closure yourself. Do not assume native relationship() semantics carry over.

Multi-select state is a plain array. That makes storage (JSON cast on the parent, or a pivot table) an explicit decision rather than a default. Decide before the first save.

Validation still runs against the submitted ID. required and exists behave against the value, not the visible option list. Confirm this in staging rather than assuming, and never rely on the picker's loaded pages as a validation source.

Pin the version and re-verify on every Filament minor bump. With 9 stars, 0 dependents, and a single maintainer, nobody else surfaces regressions for you. Budget a recurring task at each Filament upgrade to re-run asset publishing and re-exercise one field end-to-end.

Keep the fallback one line away. For fields under roughly 100 options, or where the job is strict type-ahead over a very large dataset, native searchable() with getSearchResultsUsing is simpler, better supported, and has better keyboard and screen-reader behavior. This package earns its place on browse-heavy, high-cardinality fields; everywhere else it is extra surface area with no payoff.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel