⌘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.
- Livewire modal — keyboard-triggered (
⌘K/Ctrl+K) global search dialog - Zero-config discovery — models under
app/Models/usingSearchableare auto-detected at boot - Grouped results — results organised by model type with section headers and color tokens
- Multiple drivers — Meilisearch, Algolia, Typesense, or Database
- Accent-insensitive highlight — diacritic-free queries (
padrao) match and highlight accented text (Padrão) via NFD normalization - Auto-discovered subtitles — models with
description,subtitle,excerpt,summary,bio, orbodyattributes surface them as result subtitles automatically; HTML is sanitized to plain text before display, so CMS fields render cleanly without escaped tags - Query hook — per-model
globalSearchBuilder()for custom filters, scopes, or infix matching - Recent searches — configurable history, persisted to session
- i18n — ships with
pt_BR,en, andestranslations - Dark mode — full dark mode support out of the box
- WCAG AA — accessible markup with focus management and keyboard navigation
- Any blade-icons pack —
globalSearchIcon()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 configured default prefix (heroicon-o-) - File preview & download — models implementing
HasGlobalSearchPreviewexpose 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 ascoutify:downloadbrowser event you can handle with a single listener - Tailwind v4 — utility classes inlined, override via config
composer require matheusmarnt/scoutify
php artisan scoutify:installThis will:
- Prompt for a Scout driver (
meilisearch,algolia, ortypesense) - Install the driver's Composer packages
- Publish
config/scoutify.phpandconfig/scout.php - Set
SCOUT_DRIVERin.env
Make your Eloquent models globally searchable:
php artisan scoutify:searchableThe 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:
- Import
Matheusmarnt\Scoutify\Concerns\SearchableandMatheusmarnt\Scoutify\Contracts\GloballySearchable - Add
implements GloballySearchableto the class declaration - Insert
use Searchable;as the first statement in the class body
The 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 configured prefix prepended. Change the default inconfig/scoutify.php:'icon_prefix' => 'heroicon-o-', // default; any installed pack prefix works here
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-runThen import your models into the Scout index:
php artisan scoutify:importAdd 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:
- Guests: cannot see results (always denied).
- Authenticated users: can see results if they pass a registered policy check for
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');
}
}- PDFs, images, and videos render inline inside the preview pane.
- Other types show a fallback with an external-link button.
- Authorization reuses the same
GlobalSearchAuthorizerrules as search results — the record must be visible to the current user. - Signed route (
scoutify.preview.stream) is auto-registered. No manual route publishing needed. - Temporary URLs — if the disk supports them (e.g. S3 with pre-signed URLs), Scoutify uses them directly; otherwise it streams through the signed route.
- Keyboard accessible —
Tab/Shift+Tabcycle focus between the search input and the Preview / Download buttons on the active row.Enteron a focused button activates it without navigating to the record's route. Opening the preview auto-focuses the Back button;Esccloses 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();
});| 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 |
- Installation guide — step-by-step setup, model registration, Tailwind config, customization
- Production deployment — per-driver production configuration (Meilisearch, Algolia, Typesense, Database)
composer testPlease see CONTRIBUTING for details.
MIT — see LICENSE.
