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 Search Spotlight Laravel Package

wezlo/filament-search-spotlight

A full-screen Spotlight command palette for Filament panels. Hit ⌘K to search records, resources, pages, and actions in one overlay, with recents and pins saved to localStorage. Composes Filament's GlobalSearchProvider, so globally searchable resources appear automatically. Configurable key binding,...

View on GitHub
Deep Wiki
Context7

Getting Started

Two commands and one plugin registration get you a working palette:

composer require wezlo/filament-search-spotlight
use Wezlo\FilamentSearchSpotlight\FilamentSearchSpotlightPlugin;

->plugins([
    FilamentSearchSpotlightPlugin::make(),
])

Press ⌘K inside any panel page. That is the entire install — no migrations, no tables, no queue, no observer. Recent and pinned items live in localStorage under spotlight.recent and spotlight.pinned.

The overlay is only as good as the data you already have. The Records category is a thin adapter over Filament's GlobalSearchProvider, so it surfaces exactly the resources that implement getGloballySearchableAttributes() — nothing more. Audit that first. If four of your sixteen resources implement it, your palette shows four. That gap is the difference between "this is magic" and "this is empty."

Order of operations for the first hour:

  1. Inventory getGloballySearchableAttributes(). Grep for it across app/Filament/**/Resources. Every resource missing it is an invisible record type.
  2. Tune getGlobalSearchResultTitle() / getGlobalSearchResultDetails(). Two customers named "Acme" need a disambiguator. Push the email, order number, or account ID into the details array so the result rows are scannable without opening them.
  3. Publish the config once, read it, then stop editing it.
    php artisan vendor:publish --tag=filament-search-spotlight-config
    
    Fluent calls on the plugin win over the config file, and per-panel config beats a global file the moment you have a second panel. Treat the config file as reference documentation, not the source of truth.
  4. Check your Tailwind theme. If you compile a panel theme (resources/css/filament/admin/theme.css), add the package's views to the scan list now, not after the first demo:
    @source '../../../../vendor/wezlo/filament-search-spotlight/resources/views/**/*';
    
    Then npm run build. Skip this and the overlay renders with utility classes purged — an unstyled gray slab you will waste an afternoon debugging.

First use case worth shipping: don't build a custom category. Ship with ->disableDefaultGlobalSearch() and three or four SpotlightActions that map to your team's actual daily rituals — "Create Invoice," "Clear cache," "Impersonate user," "Open queue monitor." One service-provider file, and it tells you within a week whether anyone reaches for ⌘K.

// AppServiceProvider::boot()
SpotlightAction::make('open-queue')
    ->label('Open failed-jobs queue')
    ->icon('heroicon-o-queue-list')
    ->keywords(['jobs', 'queue', 'horizon', 'failed'])
    ->group('Operations')
    ->url(fn () => url('/horizon/failed'))
    ->register();

Implementation Patterns

Split the registry by ownership, not by convenience

The global registry belongs to cross-cutting operational verbs that every panel should have. Plugin-scoped actions belong to the panel's domain. This isn't stylistic — it mirrors who gets paged when the action breaks.

// AppServiceProvider::boot() — ops, every panel
SpotlightAction::make('clear-cache')
    ->label('Clear application cache')
    ->icon('heroicon-o-bolt')
    ->keywords(['flush', 'cache', 'redis'])
    ->group('Operations')
    ->url(fn () => route('admin.cache.clear'))
    ->register();
// BillingPanelProvider — domain-specific
FilamentSearchSpotlightPlugin::make()
    ->action(
        SpotlightAction::make('open-disputes')
            ->label('Open dispute queue')
            ->icon('heroicon-o-exclamation-triangle')
            ->keywords(['chargeback', 'refund', 'dispute'])
            ->group('Billing')
            ->url('/billing/disputes'),
    )
    ->overrideActions(['legacy-export']);

The by-name override mechanic is the valuable part: a panel-scoped action named clear-cache silently replaces the global one for that panel only. One canonical registry, per-panel retuning of label/URL/keywords, no forking. Use overrideActions() only when you want to hide a global action outright, with no replacement.

Keywords are the real API surface

Labels are for discoverability on day one. Keywords are for muscle memory on day ninety. Nobody types "Impersonate user" — they type imp, sudo, as, or a customer name. Budget a minute per action thinking about synonyms, and expect to add more after literally watching someone use it. Group names are also searchable-ish in the UI sense, so consistent ->group() values (Operations, Billing, Account) help scanning more than they help matching.

Authorize everything you register yourself

Records inherits Filament's resource authorization. Your SpotlightActions and custom categories do not. They are plain URLs and labels rendered to whoever opens the palette. This is the single most common way this package leaks.

SpotlightAction::make('impersonate')
    ->label('Impersonate user')
    ->visible(fn () => auth()->user()->can('impersonate', User::class))
    ->url(fn () => route('impersonate.start'));

Wrap any category that queries non-public data:

final class InvoicesCategory implements Category
{
    public function search(string $query, int $limit): array
    {
        if (! auth()->user()->can('viewAny', Invoice::class)) {
            return [];
        }

        return Invoice::query()
            ->where('user_id', auth()->id())          // scope, don't filter after
            ->where('reference', 'like', "%{$query}%")
            ->limit($limit)
            ->get()
            ->map(fn (Invoice $i) => new SpotlightResult(
                id: "invoice:{$i->id}",
                category: 'invoices',
                title: $i->reference,
                subtitle: $i->customer_email,
                icon: 'heroicon-o-document-text',
                url: InvoiceResource::getUrl('edit', ['record' => $i]),
            ))
            ->all();
    }

    public function key(): string   { return 'invoices'; }
    public function label(): string { return 'Invoices'; }
}

Return the scope inside the query. Never post-filter a broad fetch — the $limit applies before your filter if you do, and you'll return fewer results than you should while burning a wider read.

Custom categories are cheap; use them for non-Eloquent sources

The Category contract is three methods, which makes it the right tool for anything Filament's global search can't see: settings pages, config keys, external API lookups, log files, artisan commands.

FilamentSearchSpotlightPlugin::make()->categories([
    \Wezlo\FilamentSearchSpotlight\Categories\RecordsCategory::class,
    \Wezlo\FilamentSearchSpotlight\Categories\ResourcesCategory::class,
    \Wezlo\FilamentSearchSpotlight\Categories\PagesCategory::class,
    \Wezlo\FilamentSearchSpotlight\Categories\ActionsCategory::class,
    SettingsCategory::class,
]);

Note the trade-off: categories() replaces the list. If you want three built-ins plus yours, you must re-declare all three. Toggling individual defaults is done with ->records(), ->resources(), ->pages(), ->actionsEnabled() and their false variants — those are the additive path; categories() is the nuclear one.

Keep the id field namespaced ("settings:{$key}", "invoice:{$id}") — it's the dedupe key for recents and pins.

Programmatic opening is your integration seam

The open-spotlight browser event is how you wire the palette to anything that isn't a keystroke:

<button type="button" x-on:click="$dispatch('open-spotlight')" class="...">
    Search <kbd>⌘K</kbd>
</button>

Good uses: an empty-state CTA on a dashboard, a wire:click handler that prefills a query in JS, an onboarding tour step, a custom topbar button when you've removed Filament's native search. Dispatch from Livewire via $this->dispatch('open-spotlight') in a component's mount() if you want to greet first-time users with it.

Multi-panel: configure per provider, always

// AdminPanelProvider
FilamentSearchSpotlightPlugin::make()
    ->keyBinding('mod+k')
    ->resultLimitPerCategory(10)
    ->disableDefaultGlobalSearch();

// ClientPanelProvider
FilamentSearchSpotlightPlugin::make()
    ->keyBinding('mod+k')
    ->resultLimitPerCategory(5)
    ->excludeResources([AuditLogResource::class, TenantResource::class])
    ->records()
    ->pages()
    ->resources(false)      // clients don't need the resource index
    ->disableCreateActions();

excludeResources() is doing double duty — it removes the resource from both Records and Resources, and suppresses its auto-generated "Create X" action. That's the correct tool for hiding a whole resource from a whole panel.

Watch result density

result_limit_per_category defaults to 8. With four categories, that's a 32-row palette — past the point where scanning beats typing. For most apps, 5–6 per category is the sweet spot. Bump Records higher and starve the others only if record search is genuinely the primary use case.


Gotchas and Tips

The Tailwind @source line is part of the install, and it breaks on deploy. If you build assets in CI and vendor/ is reinstalled after, or if you build before composer install, the package views aren't there to scan and the classes are purged. Pin the build order (composer install → npm ci → npm run build) or the overlay silently degrades to unstyled HTML with no error anywhere.

categories() replaces, it doesn't extend. This is the number-one confusion. Reach for ->records(false) etc. when you want to subtract; only use ->categories([...]) when you're substituting, and re-list every built-in you want to keep.

The default topbar search stays unless you remove it. You now have two search entries in the UI, one of which opens a modal and one of which opens a dropdown, with different result sets and different ranking. Either ->disableDefaultGlobalSearch() or accept the confusion. Don't ship both and hope users figure it out.

Fluent beats config, config beats nothing. Precedence is plugin call → config/filament-search-spotlight.php → package default. In a multi-panel app, a value set in the config file applies everywhere and will quietly override your mental model of "this panel is configured differently." Default to fluent.

Mousetrap syntax, not Laravel. keyBinding('mod+k') — mod maps to ⌘ on macOS and Ctrl elsewhere. If you pass something Mousetrap doesn't understand you get a dead shortcut with no console error. Test on both platforms; mod+k is fine but a hand-rolled ctrl+shift+p will fail in Safari in ways Mousetrap won't tell you about.

Nothing is server-side, and that's mostly good. No migration, no logout cleanup, no PII on the server. The flip side: recents persist across logout on a shared machine, and you cannot inspect or audit what a user searched for. If either matters, that's a "don't use this" signal, not a config flag. Pinned items surviving across users on a kiosk browser is a real support ticket waiting to happen.

Resource labels leak into the palette. The Resources category matches on plural label, Pages on navigation label. Inconsistent $navigationLabel / $pluralModelLabel / $modelLabel values — which are invisible in the sidebar because you rarely look — surface immediately as confusing palette entries. Use this as your forcing function to clean them up.

Custom categories don't get Filament's authorization for free. Covered above, repeated here because it's the one that bites in production. search() must return [] for unauthorized users; the palette is not a permission boundary and will happily render a URL to anyone who triggers it.

Auto-created "Create X" actions ignore policy nuances more than you'd like. disableCreateActions() globally, or exclude the resource, if you have resources where create exists but isn't universally allowed. Test with a low-privilege role before shipping.

Debugging an empty palette — work down this list: (1) does the resource implement getGloballySearchableAttributes()? (2) does $query match case-insensitively in your custom category? (3) is the category in the categories() list? (4) is the resource in excluded_resources? (5) did the Tailwind build run? Most "broken" reports are (1) or (5).

Icons accept BackedEnum as of 1.0.2 — you can pass a Filament Heroicon enum directly rather than the string. Prefer the enum; string heroicon names typo silently and render nothing.

Feature tests live in your app, not the package. The convention is tests/Feature/FilamentSearchSpotlight/:

php artisan test --compact tests/Feature/FilamentSearchSpotlight

Write a test per custom category asserting that an unauthorized user gets [] and an authorized one gets the expected SpotlightResult shape. The package's JS behavior (arrow wrapping, escape) isn't worth browser-testing — the authorization and query scoping are.

Localization exists (ckb, cs as of recent releases) via resources/lang/*/spotlight.php. If you ship a non-English panel, publish and override rather than patching vendor — placeholders, empty-state copy, and group names all flow through it.

Extension points, ranked by value per line of code: custom Category (highest — arbitrary data sources), SpotlightAction registry (highest — daily verbs), per-resource global search tuning (high — improves every existing result), fluent plugin config (medium), open-spotlight dispatch (medium — integration seam). Custom category and action registry together cover roughly 90% of what anyone actually wants from a command palette.

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.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc