gosuperscript/filament-spotlight

A Cmd+K command menu for Filament panels: search and quick actions with a fluent, hookable API.

Maintainers

Package info

github.com/gosuperscript/filament-spotlight

pkg:composer/gosuperscript/filament-spotlight

Transparency log

Statistics

Installs: 318

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-03 12:02 UTC

This package is auto-updated.

Last update: 2026-08-03 12:02:18 UTC


README

A ⌘K command menu for Filament 5 panels — search and quick actions, built on cmdk with a fluent, hookable PHP API.

The Spotlight command menu open over a Filament panel, showing custom commands and the panel's navigation
  • Command menu opened with ⌘K / Ctrl+K, with fuzzy matching, keyboard navigation, and grouped results.
  • Navigation index: every page and resource in your panel is instantly searchable, respecting visibility and authorization.
  • Global search: records from your resources' global search appear while you type.
  • Custom commands: register actions with a fluent API — navigate to URLs, dispatch Livewire events, or run server-side closures.
  • Context-aware: commands for the page — or record — the user is viewing are pinned to the top, Linear-style.
  • Keybindings: Linear-style per-command shortcuts, shown on the item and working panel-wide — even before the menu is opened.
  • Hookable: pages, resources, plugins, and packages can all contribute commands.
Searching in the Spotlight command menu, mixing matching commands with global search records

The UI ships as a self-contained React island (react + cmdk bundled, ~28 KB gzipped, loaded once and cached). Your app needs no Node build step and no theme changes. All command definitions, search, and authorization stay server-side — the browser only ever sees display payloads and command IDs.

Requirements

  • PHP 8.3+
  • Laravel 11 or 12
  • Filament ^5.0

Installation

composer require gosuperscript/filament-spotlight
php artisan filament:assets

Register the plugin in your panel provider:

use Superscript\FilamentSpotlight\SpotlightPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(SpotlightPlugin::make());
}

That's it — press ⌘K in your panel. Navigation and global search work out of the box.

Registering commands

use Superscript\FilamentSpotlight\Commands\Command;
use Superscript\FilamentSpotlight\Commands\CommandGroup;
use Superscript\FilamentSpotlight\SpotlightPlugin;

SpotlightPlugin::make()
    ->groups([
        CommandGroup::make('maintenance')->label('Maintenance')->sort(50),
    ])
    ->commands([
        // Navigate to a URL
        Command::make('open-horizon')
            ->label('Open Horizon')
            ->icon(Heroicon::OutlinedQueueList)
            ->url(fn (): string => route('horizon.index'), shouldOpenInNewTab: true),

        // Run a closure on the server
        Command::make('clear-cache')
            ->label('Clear cache')
            ->keywords(['flush', 'artisan'])
            ->group('maintenance')
            ->visible(fn (User $user): bool => $user->isAdmin())
            ->action(function () {
                Artisan::call('cache:clear');

                Notification::make()->title('Cache cleared')->success()->send();
            }),

        // Dispatch a Livewire event in the browser (no server roundtrip)
        Command::make('toggle-sidebar')
            ->label('Toggle sidebar')
            ->dispatch('sidebar-toggle'),
    ]);

A command defines exactly one behaviour: action() (server-side closure), url(), or dispatch(). An action() closure that returns a string redirects the browser to it.

Closures everywhere

Almost every method accepts a closure, evaluated lazily on the server with Filament-style named and typed parameter injection — $command, $panel, $user, and $context are available:

->commands(fn (User $user, Panel $panel): array => [
    Command::make('my-account')
        ->label("Signed in as {$user->name}")
        ->url(fn (): string => ProfilePage::getUrl()),
])

commands() can be called multiple times; other plugins and packages can contribute too:

SpotlightPlugin::get()->commands([...]);

Keybindings

Give a command a Linear-style keyboard shortcut:

Command::make('assign')
    ->label('Assign to…')
    ->keybinding('a')             // single key, or a combo like 'mod+shift+m'
    ->action(...),

Command::make('go-to-inbox')
    ->label('Go to inbox')
    ->keybinding('g i')           // a two-step chord: G, then I
    ->url(...),

The shortcut renders as chips on the item ( M on macOS, Ctrl Shift M elsewhere; G then I for the chord) and runs the command directly:

  • While the menu is closed, shortcuts work anywhere in the panel. Shortcuts without a modifier (like a or g i) are suppressed while a text field is focused, so they never steal keystrokes.
  • While the menu is open, single-step shortcuts with a modifier still fire; plain keys and chords type into the search input instead.
  • Chords are two space-separated steps: the first key arms the chord, and the second must follow within a second. Keep chord steps modifier-free.

Keybound commands ship with the page, so shortcuts work before the menu has ever been opened — visibility and authorization are still enforced server-side when the command executes. Contextual commands can be keybound too: their shortcuts only work while the user is on the page (or record) offering them. Dynamic (provider) commands can display a keybinding as well, but it only fires while they are listed in the open menu.

Visibility & authorization

Command::make('danger-zone')
    ->visible(fn (User $user): bool => $user->isAdmin())  // or ->hidden(...)
    ->authorize('manage-settings')                        // Gate ability
    ->action(...);

Both checks run when the index is built and again when a command is executed — hiding a command client-side is presentation only; the server is the enforcement point (403).

Commands from pages & resources

Any panel page or resource can contribute commands by implementing HasSpotlightCommands:

use Superscript\FilamentSpotlight\Commands\Command;
use Superscript\FilamentSpotlight\Contracts\HasSpotlightCommands;

class Settings extends Page implements HasSpotlightCommands
{
    public static function getSpotlightCommands(): array
    {
        return [
            Command::make('settings:reset')
                ->label('Reset settings')
                ->action(fn () => static::reset()),
        ];
    }
}

Contextual commands

Linear-style context awareness: commands scoped to the page the user is currently on are pinned to the top of the menu, under a chip showing the context — "User · Jane Cooper" on a record page, "Users" on the list. Pressing on an empty query steps out of the context, leaving just the global commands; reopening the menu steps back in.

Implement HasContextualSpotlightCommands on a page or resource — it is only consulted while the user is there, and receives a PageContext with the current page, resource, and (on record pages) the resolved record:

use Superscript\FilamentSpotlight\Commands\Command;
use Superscript\FilamentSpotlight\Contracts\HasContextualSpotlightCommands;
use Superscript\FilamentSpotlight\PageContext;

class UserResource extends Resource implements HasContextualSpotlightCommands
{
    public static function getContextualSpotlightCommands(PageContext $context): array
    {
        // On /admin/users/3/edit — commands for that user:
        if ($user = $context->record) {
            return [
                Command::make("users:{$user->getKey()}:deactivate")
                    ->label('Deactivate')
                    ->icon(Heroicon::OutlinedNoSymbol)
                    ->authorize('update', $user)
                    ->action(fn () => $user->deactivate()),
            ];
        }

        // On /admin/users — commands for the list page:
        return [
            Command::make('users:export')
                ->label('Export users')
                ->action(fn () => /* ... */),
        ];
    }
}
The command menu on a record's edit page, with commands for that record pinned to the top under the record's title

Contextual commands render ungrouped at the top — the chip carries the context — unless they set an explicit ->group(). Any other command can be pinned into the contextual section with ->contextual().

The client reports its current URL when the menu opens; the server resolves it through the router. The page must belong to the panel, and records resolve through the resource's scoped Eloquent query, so tenancy and query scoping apply. Like provider commands, contextual command names must be deterministic (include the record key): the command is re-materialized from the same URL on execute, and visibility/authorization are re-checked before anything runs — make those checks record-aware where it matters, as in ->authorize('update', $user) above.

Plugin-registered commands() closures can also become context-aware through the injected $record and $pageContext:

->commands(fn (?Model $record): array => $record ? [
    Command::make("copy-id-{$record->getKey()}")
        ->label('Copy record ID')
        ->dispatch('copy-to-clipboard', ['text' => $record->getKey()]),
] : [])

Dynamic commands (search providers)

For results that depend on what the user types (recent documents, external APIs, …), implement CommandProvider. Providers are queried server-side on every debounced keystroke:

use Superscript\FilamentSpotlight\Contracts\CommandProvider;
use Superscript\FilamentSpotlight\SearchContext;

class DocumentProvider implements CommandProvider
{
    public function search(SearchContext $context): array
    {
        return Document::search($context->query)
            ->take(5)
            ->get()
            ->map(fn (Document $document) => Command::make("documents:{$document->id}")
                ->label($document->title)
                ->group('documents')
                ->url($document->url))
            ->all();
    }
}

SpotlightPlugin::make()->providers([DocumentProvider::class]);

Important

Provider command names must be deterministic (e.g. documents:{id}): when a provider command is executed, the provider is re-run with the same query to re-materialize the command by its name, and visibility/authorization are re-checked before anything runs.

Besides query, panel, and user, the SearchContext exposes pageContext — the resolved current page, resource, and record — so providers can tailor results to where the user is.

Configuration

SpotlightPlugin::make()
    ->keybindings(['mod+k', 'mod+/']) // 'mod' = ⌘ on macOS, Ctrl elsewhere
    ->placeholder('What do you need?')
    ->navigation(false)               // drop pages/resources from the index
    ->globalSearch(false);            // drop record results

globalSearch() also accepts a GlobalSearchProvider (instance or class-string) to search through directly — even on a panel whose own global search is disabled, so the command menu can be the only search surface without Filament's topbar field:

use Filament\GlobalSearch\Providers\DefaultGlobalSearchProvider;

SpotlightPlugin::make()->globalSearch(DefaultGlobalSearchProvider::class);

Record results are grouped under the resource's plural label; register a CommandGroup of the same name to control its heading and position.

Opening the menu programmatically

From PHP (any Livewire component):

$this->dispatch('filament-spotlight:open'); // also :close, :toggle

From JavaScript:

window.FilamentSpotlight.open() // .close(), .toggle()

Theming

The menu is styled with Filament's CSS custom properties (--gray-*, --primary-*), so it follows your panel's palette and dark mode automatically — nothing to configure. To restyle it, target the [cmdk-*] attributes under .fi-spotlight in your own CSS.

Development

composer install && npm install
npm run build          # bundle resources/js + css into dist/
composer test          # Pest
composer analyse       # PHPStan
composer format        # Pint

dist/ is committed so installing apps never need Node.

Demo app & screenshots

workbench/ contains a demo panel (see testbench.yaml) used to generate the README screenshots. Run it with:

vendor/bin/testbench workbench:build   # migrate + seed + publish assets
vendor/bin/testbench serve             # demo panel on http://127.0.0.1:8000/admin

It logs in automatically via /_workbench (demo@example.com / password). To regenerate the screenshots in art/:

npx playwright install chromium        # once
npm run screenshots

License

MIT