happenv-com / filament-model-search
Filament integration for happenv-com/laravel-model-search: a command palette, table search, select search and global search backed by one record index.
Package info
github.com/happenv-com/filament-model-search
pkg:composer/happenv-com/filament-model-search
Requires
- php: ^8.4
- filament/filament: ^5.11
- happenv-com/laravel-model-search: ^0.1 || ^0.2
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- livewire/livewire: ^4.4
Requires (Dev)
- ergebnis/composer-normalize: ^2.52
- happenv-com/filament-phpstan-macros: ^1.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-livewire: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- rector/rector: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 21:42:16 UTC
README
Filament integration for happenv-com/laravel-model-search: a record palette, table search, select search and global search, all answered by one record index.
The core package owns the index, the ranking, the privacy of the phrase and the rate limit. This package only connects it to Filament surfaces and keeps Filament's authorization in charge of what a user may see.
- Requirements
- Installation
- Binding resources
- What happens until the index is ready
- Tables
- Selects
- Global search
- Record palette
- Configuration
- Coming from another palette
- Static analysis
- Translations
- Testing
- Licence
Requirements
- PHP 8.4+
- Laravel 13 (
illuminate/*^13.0) - Filament ^5.11 and Livewire ^4.4
happenv-com/laravel-model-search^0.1, which brings its own requirements:- PostgreSQL 18+ with the
pg_trgmextension (CREATE EXTENSION pg_trgm). Under the core'sdatabasedriver (small tables, local development) there is no index and no extension is needed. - a role that owns the watched tables for
php artisan model-search:sync(migrateruns it), and a direct, not transaction-pooled, connection for it.
- PostgreSQL 18+ with the
Read the core's README before you define searches: definitions, fields, stages, reveal rules, version() bumps and deployment are described there and not repeated here.
Installation
composer require happenv-com/filament-model-search
The service provider is discovered automatically. Register the plugin on every panel that should offer the search:
use Happenv\FilamentModelSearch\FilamentModelSearchPlugin; public function panel(Panel $panel): Panel { return $panel ->plugin( FilamentModelSearchPlugin::make() ->resources([ OrderResource::class, CustomerResource::class, ]) ->palette(), // default: true ); }
resources() lists the resources that take part in the palette and in global search. The plugin only stores class names; the bindings are made when the panel registers, and nothing about the user or the request is captured then (a panel is registered once per worker under Octane).
Binding resources
A resource shows the records of one model, and the model has one search definition from the core. There are two ways to tell the package which.
With the trait, on the resource itself:
use Happenv\FilamentModelSearch\Concerns\InteractsWithModelSearch; final class OrderResource extends Resource { use InteractsWithModelSearch; public static function modelSearch(): string { return OrderSearch::class; } }
The class must also be listed in FilamentModelSearchPlugin::make()->resources([...]). A listed class that does not use the trait is refused with a LogicException when the panel registers. The trait overrides getGlobalSearchResults(), so a resource that declares its own getGlobalSearchResults() is refused at registration too: the trait's method would never run and the resource would silently keep searching natively. Drop your override to opt in.
Explicitly, for a resource you cannot or do not want to touch (a resource of another package, for example):
use Happenv\FilamentModelSearch\Facades\FilamentModelSearch; FilamentModelSearch::resource(OrderSearch::class, OrderResource::class);
Call it from a service provider's boot(). A binding made this way belongs to every panel; the plugin's bindings belong to the panel that listed the resource. Reads resolve against the current panel, and a resource counts only on the panels where it is bound.
Binding the same pair twice changes nothing. Binding a model to a second, different resource on an overlapping panel, or a definition to a resource that shows another model, throws a LogicException.
The core is not asked whether it knows the definition when you bind: bindings are made while panels register, so an unknown definition fails on the first search instead.
Access is never decided at registration. The palette and global search ask canAccess() && canViewAny() of the resource for the current user on every call, so with one resource registered once per worker, a user who may not open it never gets it searched, and another user in the same process does. Tables and selects follow native Filament: the page or the form that shows them is the gate.
What happens until the index is ready
The index is built by the core in the background (model-search:sync, run by migrate). Until ModelSearches::isReady(<model>) is true for a model:
- tables, selects and global search keep Filament's native behaviour for that model: the native
ILIKEsearch, the native select search, the resource's own global search. Nothing is replaced, and nothing is half-served; - the palette always asks the core, which answers from the source tables when the definition's
fallback()allows it and flags the answer asindexBuilding; the palette tells the user results may be incomplete.
Readiness is asked again on every search, with the answer remembered for readiness_cache_seconds (default 60), so a table or select that was native before the index was ready switches to the core's search by itself, without a restart.
Tables
use Filament\Tables\Table; public static function table(Table $table): Table { return $table ->query(fn () => Order::query()->with('customer')) ->columns([...]) ->emptyStateHeading('No orders yet') ->emptyStateDescription('Orders appear here when customers check out.') ->modelSearch(); }
modelSearch(?string $searchClass = null, ?array $only = null) turns the table's global search box into the core's search. $searchClass defaults to the definition bound to the table's model; passing one that searches another model throws an InvalidArgumentException. $only restricts the search to fields of the given kinds (->modelSearch(only: ['number', 'email'])). Search per column is not touched.
A table that has no bound definition and is given no $searchClass stays native: modelSearch() returns it unchanged.
Viewer's language. Tables, selects, global search and the palette all search in the language of the current request (app()->getLocale()), so Müller also finds mueller for de.
Fields found through a parent (via). An order found by its customer's e-mail is found only through the customer's scope: the parent resource must be bound on the current panel and the user must be able to open it, otherwise nothing is found through it. The scopes are those of the bound resources and are asked on every search, for tables, selects and global search as for the palette.
Call order matters:
- Call
modelSearch()after the table's query (->query()or the resource's own), so it knows the model. - Call it after your own
emptyStateHeading()andemptyStateDescription(). It keeps what you set and shows it whenever the search did not fail, and shows its own text when the search was too slow, too broad, refused or answered from an index that is still building. A heading set aftermodelSearch()replaces the package's and hides those states, and the user would read "No orders yet" for a search that timed out.
The empty state is Filament's own empty state component. These states are distinct and none of them pretends to be "no results":
| Outcome | Shown | Rows |
|---|---|---|
Timed out (SearchTimedOut) |
"Search timed out" | none |
Too broad (SearchTooBroad) |
"Too many matches" | none |
Index not ready (SearchIndexNotReady) |
"Search index is being built" | none |
Rate limited (SearchRateLimited) |
"Too many searches" | none |
| Search unavailable (privacy gate) | "Search unavailable" | none |
| Nothing matched | your own empty state | none |
Pagination and the total stay consistent because a refused search matches nothing in the table's own query rather than throwing.
The search is narrowed by the table's own query (its scope, soft deletes, tenant constraints) and never goes outside it. The core sees the scope as a snapshot taken before the narrowing, and a custom select() on the table query is reset to <table>.* for that scope: a select never changes which rows belong to the table, so the scope cannot depend on it. The select you wrote still applies to the rows shown.
The phrase is bound as a parameter by the core and never reaches a log, an event or an exception message of this package.
Selects
use Filament\Forms\Components\Select; use Filament\Tables\Filters\SelectFilter; Select::make('customer_id') ->relationship('customer', 'name', modifyQueryUsing: fn ($query) => $query->where('active', true)) ->searchable() ->modelSearch(); SelectFilter::make('customer_id') ->relationship('customer', 'name') ->searchable() ->modelSearch();
modelSearch(?string $searchClass = null, ?array $only = null, bool $stages = true):
relationship()first. Without it aLogicExceptionis thrown: the select has no related model to search.$searchClassdefaults to the definition bound to the related model; a class that searches another model throws.$onlyrestricts the kinds searched.stages: falsesearches every stage at once instead of the first stage that answers.- Only the search is replaced. The labels of values that are already selected still come from the relationship query, so they keep rendering whatever the index holds.
- Options never leave the relationship query (including its
modifyQueryUsing) or the related resource's own global search scope, and they come in the core's ranking order, up to the select's options limit. - Until the index serves the related model the select searches natively. An empty phrase is native too.
A select has no room for a message. A search that timed out, was refused (too broad, rate limited, unavailable), or hits an index that is still building and found nothing offers an empty list. Options that were found are shown even when the answer is flagged as incomplete: dropping correct, correctly scoped options would be worse than showing them, and the core already marks the answer.
Global search
final class OrderResource extends Resource { use InteractsWithModelSearch; public static function modelSearch(): string { return OrderSearch::class; } protected static ?string $recordTitleAttribute = 'number'; }
List the resource in the plugin (see Binding resources). Then:
- Until the index serves the model, the resource's global search stays Filament's own.
- Once it does, the index answers inside
getGlobalSearchResults(), in the core's ranking order, withingetGlobalSearchEloquentQuery()(so the resource's own scope, soft deletes and tenant constraints apply), up togetGlobalSearchResultsLimit(). - Every stage is searched at once (
withoutStages()), as native Filament global search returns every record: this surface has no room for a note saying that only the open orders were searched. The palette, which has the room, starts with the first stage and offers "Search all". - Titles, urls, details and actions are the resource's own (
getGlobalSearchResultTitle(),getGlobalSearchResultUrl(),getGlobalSearchResultDetails(),getGlobalSearchResultActions()); the records are loaded with the scope's own eager loads. - A line telling what matched is added to the details: the value when the core's reveal rule lets the viewer see it, and otherwise only "Matched by: ". A value that equals the title adds nothing, and a native detail of the same name is kept.
modifyGlobalSearchQuery()is not applied on the index path: it works on Filament's nativeILIKEquery, which does not exist there. Put constraints intogetGlobalSearchEloquentQuery()instead, or into the core's definition.- Access (
canAccess()andcanViewAny()) is checked on every call, not when the panel boots; a user who may not open the resource gets no results from it.
A global search has no room for a message. A search that timed out, was refused, or hits an index that is still building without having found anything shows no results. Results that were found are shown whatever the flags say.
Record palette
A modal that searches every bound resource at once (one SearchQuery::palette() call, so one rate-limit charge), opened from a button next to the panel's global search and by a keyboard shortcut. FilamentModelSearchPlugin::make()->palette(false) or palette.enabled = false switches it off. It is also absent for a user who may open none of the searched resources, and that is decided when the page renders, not at boot.
- Type at least two characters. The search is debounced (
palette.debounce_ms). - Groups follow the navigation order of the resources, except that a group holding an exact hit comes first; at most
palette.per_grouprecords each. Each group shows the icon of its resource, each record the resource's own title and url, and what matched (the value only when the core lets the viewer see it; otherwise "Matched by: "). - A record found through a related one (a
viafield) is told by the related resource's name. It is found only if the user may also open the related resource: the parent's scope is the one of its resource, filled in by the palette. Otherwise nothing is found through it. - Enter opens the record when exactly one hit of the whole palette is an exact one.
- "Search all" appears when the answer came from a stage (for example only open orders): it repeats the search over every stage. It applies to the phrase on screen and is reset when the phrase changes, so the user asks again for the next phrase.
- "Search for similar" (also
mod+enterin the modal) finds names close to what was typed (typos). It is offered for a phrase that has letters and is not shaped like an identifier (an order number or an e-mail), while the groups are not full. It is never run unless the user asks: not while typing, not as a fallback. The run is onesimilar()per resource, each charged to the rate limit on its own, so a limit hit on a later resource drops the results of the whole run. - A search that timed out, an index that is still building, a refused search and an empty answer are different states with their own notices. Records that came back are shown whatever the flags say, with a notice.
- Access is checked on every search and again when a record is opened. What was found is kept in a locked Livewire property: the browser can read it, not change it.
- The component is isolated (
#[Isolate]), so its requests are not bundled with other components' updates; there is no polling. - The phrase is never logged, and the palette adds nothing of it to a query other than through the core.
Configuration
Publish the file if you want to change anything:
php artisan vendor:publish --tag=filament-model-search-config
// config/filament-model-search.php return [ 'palette' => [ 'enabled' => true, 'keybindings' => ['mod+shift+k'], 'debounce_ms' => 250, 'per_group' => 5, ], // How long the readiness of an index is remembered, in seconds. 'readiness_cache_seconds' => 60, ];
keybindings is a list of shortcuts in the form mod+shift+k (mod is Command on macOS and Control elsewhere); several may be given. Characters other than letters, digits and +/- are stripped. An empty list leaves the button as the only way in.
The rest of the behaviour (timeouts, rate limit, privacy, word handling) is the core's configuration, config/model-search.php.
Coming from another palette
If you use a spotlight-style palette that searches records, you can keep it for commands and navigation and let this one answer records, or switch the other one's record search off. The two do not share an index and do not know each other. When you move a resource over:
- Write the core's definition for the model and run
migrate(ormodel-search:sync) so the index exists. - Bind the resource and list it in the plugin.
- Remove the other palette's provider for the same resource, so a record is not offered twice.
The default shortcut is mod+shift+k; if your other palette already uses it, change palette.keybindings.
Static analysis
modelSearch() is registered as a macro on Filament\Tables\Table, Filament\Forms\Components\Select and Filament\Tables\Filters\SelectFilter. Filament's macros use its own Macroable trait, which Larastan does not know, so a host that analyses its code at a high level sees Call to an undefined method ...::modelSearch() and, if it silences that error, loses the analysis of everything chained after the call.
happenv-com/filament-phpstan-macros (a development dependency, no configuration with phpstan/extension-installer) teaches PHPStan Filament macros, reading the registered closures at analysis time. Larastan boots your application, which runs this package's service provider, so the macros exist when PHPStan runs.
composer require --dev larastan/larastan happenv-com/filament-phpstan-macros
Without phpstan/extension-installer, include vendor/happenv-com/filament-phpstan-macros/extension.neon in your phpstan.neon. This package's own phpstan/macroable.stub is for analysing this package (it gives Macroable::macro() the @param-closure-this it needs so the macro closures see $this); it is not shipped as an extension and hosts do not need it.
Translations
Every text is in en, pl and de under the filament-model-search:: namespace. Publish to change them:
php artisan vendor:publish --tag=filament-model-search-translations
Files: match-line.php ("Matched by"), record-palette.php (the modal) and search-outcome.php (empty states and notices for timeouts, broad searches, rate limits and indexes that are still building).
Testing
composer test # Pest, needs PostgreSQL 18 with pg_trgm composer lint # Pint composer analyse # PHPStan level 8 vendor/bin/rector --dry-run
Licence
MIT. See LICENSE.md.