⌘K global search modal for Laravel — multi-model Livewire UI powered by Scout.
📘 Documentation: https://matheusmarnt.github.io/scoutify/
Drops a production-ready ⌘K search experience into any Laravel application. Register Eloquent models, choose a Scout driver, and ship a keyboard-triggered modal that queries multiple model types simultaneously, groups results by type, and persists recent search history to session.
⌘K / Ctrl+K) global search dialogapp/Models/ using Searchable are auto-detected at bootpadrao) match and highlight accented text (Padrão) via NFD normalizationdescription, subtitle, excerpt, summary, bio, or body attributes surface them as result subtitles automatically; HTML is sanitized to plain text before display, so CMS fields render cleanly without escaped tagsglobalSearchBuilder() for custom filters, scopes, or infix matchingpt_BR, en, and es translationsglobalSearchIcon() accepts any icon name from any Blade Icons pack installed via Composer (e.g. ri-*, tabler-*, mdi-*); fully-qualified names are auto-detected by matching against all registered pack prefixes and passed through as-is; short names fall back to the registered prefix (heroicon-o- by default; override via Scoutify::types()->iconPrefix() in a service provider)HasGlobalSearchPreview expose an inline file preview pane inside the modal. PDFs, images, and videos render natively; any other type falls back to an external-link/download button. Download is opt-in and dispatches a scoutify:download browser event you can handle with a single listenercomposer require matheusmarnt/scoutify
php artisan scoutify:install
This will:
meilisearch, algolia, or typesense)config/scoutify.php and config/scout.phpSCOUT_DRIVER in .envMake your Eloquent models globally searchable:
php artisan scoutify:searchable
The command discovers Eloquent models under app/Models/, prompts you to pick which to register (or pass --all), and automatically edits each chosen model file to:
Matheusmarnt\Scoutify\Concerns\Searchable and Matheusmarnt\Scoutify\Contracts\GloballySearchableimplements GloballySearchable to the class declarationuse Searchable; as the first statement in the class bodyThe command then rebuilds the type manifest so models appear in the UI immediately.
The Searchable trait provides sensible defaults for every interface method. Override as needed:
public function globalSearchTitle(): string { return $this->title; }
public function globalSearchSubtitle(): ?string { return $this->author; }
public function globalSearchUrl(): string { return route('articles.show', $this); }
public static function globalSearchGroup(): string { return 'Articles'; }
public static function globalSearchLabel(): string { return 'Articles'; } // UI chip label
public static function globalSearchIcon(): string { return 'heroicon-o-document-text'; }
public static function globalSearchColor(): string { return 'blue'; }
Icon packs:
globalSearchIcon()accepts any icon name supported by Blade Icons. Fully-qualified names are auto-detected by matching against all packs registered via Composer service providers — not just those declared inconfig/blade-icons.php. Install any pack and use its prefix directly:composer require andreiio/blade-remix-icon # ri-* composer require ricard0liveira/blade-tabler-icons # tabler-*public static function globalSearchIcon(): string { return 'ri-customer-service-2-fill'; } public static function globalSearchIcon(): string { return 'tabler-home'; }Short names (e.g.
user) get the registered prefix prepended (heroicon-o-by default). Override in a service provider:use Matheusmarnt\Scoutify\Facades\Scoutify; Scoutify::types()->iconPrefix('ri-');
globalSearchSubtitle()auto-discovery: if your model has adescription,subtitle,excerpt,summary,bio, orbodyattribute, the trait returns it automatically — HTML is sanitized to plain text (tags stripped, entities decoded, whitespace collapsed) then truncated to 150 chars. Override only when you need custom logic or a different field.
Use --dry-run to preview edits without touching files:
php artisan scoutify:searchable --dry-run
Then import your models into the Scout index:
php artisan scoutify:import
Add to your layout:
{{-- Desktop trigger: pill with label + ⌘K badge, visible on lg+ --}}
<x-scoutify::gs.trigger class="hidden lg:inline-flex" />
{{-- Mobile trigger: 44×44 px icon-only button, hidden on lg+ --}}
<x-scoutify::gs.trigger-mobile />
{{-- Modal: must be at root layout level, AFTER {{ $slot }} --}}
{{ $slot }}
<livewire:scoutify::modal />
Modal placement:
<livewire:scoutify::modal />must live at the root of your layout, outside any collapsible or conditionally-rendered container (sidebar, drawer, off-canvas nav, etc.). Livewire does not initialise components inside collapsed containers — placing the modal inside a collapsed sidebar means it will not mount until the sidebar is opened, causing the trigger to appear broken. The trigger component (<x-scoutify::gs.trigger />) can go anywhere.
Override globalSearchBuilder() on any model to apply custom filters, scopes, or driver-specific options:
use Laravel\Scout\Builder;
public function globalSearchBuilder(Builder $builder, string $query): Builder
{
return $builder->where('published', true);
}
Meilisearch note: Meilisearch uses word-boundary prefix search. Substrings that are not word-prefixes (e.g.
"ano"inside"Mariano") return no results. If you need substring (infix) matching, overrideglobalSearchBuilder()to configure Meilisearch'sattributesToSearchOnor switch to thedatabasedriver which usesLIKE-based search.
Any element can open Scoutify without the official trigger component.
Alpine (recommended):
<button x-data @click="$dispatch('scoutify:open')">Search</button>
Plain JS / any context:
window.dispatchEvent(new CustomEvent('scoutify:open'))
Inside a Livewire component:
<button wire:click="$dispatchTo('scoutify::modal', 'scoutify:open')">Search</button>
Do not use
wire:click="$dispatch('scoutify:open')"on plain Blade elements — outside a Livewire component tree, Livewire.js never initialises those directives.
By default, Scoutify is secure-by-default:
view (e.g. Gate::check('view', $record)). If no policy exists for the model, authenticated users are allowed by default.To customize this behavior per model, implement the HasGlobalSearchVisibility contract and use the fluent VisibilityRule builder:
use Matheusmarnt\Scoutify\Authorization\VisibilityRule;
use Matheusmarnt\Scoutify\Contracts\HasGlobalSearchVisibility;
class Article extends Model implements GloballySearchable, HasGlobalSearchVisibility
{
use Searchable;
public function globalSearchVisibility(): VisibilityRule
{
return VisibilityRule::make()
->visibleToGuests() // expose to non-authenticated visitors
->orWhenAuthenticated() // OR when authenticated +
->policy('view') // passes registered policy
->orPermission('view-articles') // OR has Spatie permission
->orRole('admin') // OR has Spatie role
->orAttribute('is_active'); // OR has boolean attribute true
}
}
| Rule | Description |
|---|---|
->visibleToGuests() |
Allows guests to see results from this model. |
->policy(ability, ...args) |
Checks Gate::check(ability, $record, ...args). |
->permission(name) |
Checks Spatie hasPermissionTo(). Supports array for multiple. |
->role(name) |
Checks Spatie hasRole(). Supports array for multiple. |
->attribute(name, expected) |
Compares $record->name with expected (default true). |
->using(Closure) |
Custom logic: fn($record, $user) => bool. |
Use ->mode(VisibilityMode::All) to require all rules to pass (logical AND) instead of any (logical OR).
Spatie Integration:
->permission()and->role()requirespatie/laravel-permission. Scoutify detects it automatically and fails closed if the package is missing when these rules are used.
Customize the default behavior in config/scoutify.php:
'authorization' => [
'default' => 'secure', // secure | permissive | gate-only
'gate_ability' => 'view', // ability used for policy/gate checks
],
secure (default): Guest denied, Auth checks gate if policy/gate exists, else allow.permissive: Everyone allowed.gate-only: Everyone (including guest if gate closure allows) must pass gate check; fails closed if gate/policy is missing.Any model can expose an inline file preview pane inside the search modal by implementing HasGlobalSearchPreview:
use Matheusmarnt\Scoutify\Contracts\HasGlobalSearchPreview;
use Matheusmarnt\Scoutify\Support\PreviewDto;
class Document extends Model implements GloballySearchable, HasGlobalSearchPreview
{
use Searchable;
public function globalSearchPreview(): ?PreviewDto
{
// Storage-based file (disk + path)
return PreviewDto::fromDisk(
disk: 'documents',
path: $this->file_path,
filename: $this->original_name, // optional; defaults to basename($path)
);
// OR: external / CDN URL
// return PreviewDto::fromUrl('https://cdn.example.com/file.pdf');
}
}
GlobalSearchAuthorizer rules as search results — the record must be visible to the current user.scoutify.preview.stream) is auto-registered. No manual route publishing needed.Tab / Shift+Tab cycle focus between the search input and the Preview / Download buttons on the active row. Enter on a focused button activates it without navigating to the record's route. Opening the preview auto-focuses the Back button; Esc closes the pane.Implement the download by listening to the scoutify:download browser event:
window.addEventListener('scoutify:download', (e) => {
const a = document.createElement('a');
a.href = e.detail.url;
a.download = e.detail.filename ?? '';
a.click();
});
PreviewDto reference| Factory method | When to use |
|---|---|
PreviewDto::fromDisk(disk, path, ...) |
File lives on a Laravel filesystem disk |
PreviewDto::fromUrl(url, ...) |
File is already a publicly-accessible URL |
Optional parameters: mime, filename, sizeBytes, view (custom Blade view), ttl (signed URL TTL in seconds, default 3600).
| Command | Description |
|---|---|
scoutify:install |
Install driver packages, publish config, configure backend |
scoutify:doctor |
Verify driver config and backend connectivity |
scoutify:searchable |
Register models as globally searchable and rebuild manifest |
scoutify:rebuild |
Rebuild the type manifest from app/Models/ |
scoutify:import |
Import registered models into Scout index |
scoutify:flush |
Flush registered models from Scout index |
scoutify:sync |
Flush then re-import |
Scoutify ships a two-tier AI documentation mechanism so any AI assistant can access current, version-pinned documentation and scaffold correct PHP code.
Layer 1 — static files (any AI client, zero install):
https://matheusmarnt.github.io/scoutify/llms.txt
https://matheusmarnt.github.io/scoutify/llms-full.txt
Layer 2 — MCP server (Claude Code, Cursor, Codex, Gemini, Windsurf, Copilot, Cline):
# Claude Code
claude mcp add scoutify -- npx -y @matheusmarnt/scoutify-mcp
# All other MCP clients — add to your mcpServers config:
# { "command": "npx", "args": ["-y", "@matheusmarnt/scoutify-mcp"] }
The MCP server exposes 8 tools: search_docs, get_page, list_pages, get_antipatterns, scaffold_searchable_model, scaffold_visibility_rule, scaffold_theme_config, validate_snippet.
Moving from v1.x to v2.x requires updating your composer.json constraint and removing legacy config keys before running composer update. Skipping this order causes a RuntimeException crash in the post-update-cmd step.
composer test
Please see CONTRIBUTING for details.
MIT — see LICENSE.
How can I help you explore Laravel packages today?