matheusmarnt/scoutify
Scoutify adds a production-ready ⌘K global search modal to Laravel apps. Built with Livewire and powered by Scout, it queries multiple Eloquent models at once, groups results by type, auto-discovers Searchable models, and keeps recent search history in session.
Scoutify is a drop-in Livewire component: install it, tag a few models, and you get a ⌘K / Ctrl+K modal that fans a single query out across multiple Eloquent models via Laravel Scout.
Minimum viable install (do it on a branch, not main):
composer require matheusmarnt/scoutify
php artisan scoutify:install # picks driver, publishes config/scoutify.php + config/scout.php, sets SCOUT_DRIVER
php artisan scoutify:searchable --all --dry-run # ALWAYS preview; this command rewrites your model files
php artisan scoutify:searchable --all
php artisan scoutify:import # push records into the Scout index
Layout wiring — the modal must be a root-level sibling, not nested inside a sidebar/drawer:
{{-- header/nav: anywhere --}}
<x-scoutify::gs.trigger class="hidden lg:inline-flex" />
<x-scoutify::gs.trigger-mobile />
{{ $slot }}
{{-- root level, last thing before </body> --}}
<livewire:scoutify::modal />
Where to look first, in this order:
config/scoutify.php — authorization.default (secure is the shipped default and will look like a bug when guests get zero results), plus history length, download opt-in, and any per-type result caps.vendor/matheusmarnt/scoutify/src/Contracts/ — GloballySearchable, HasGlobalSearchVisibility, HasGlobalSearchPreview. Three contracts, that's the whole extension surface. Read them before reading anything else.First use case to ship: an internal admin/support back office with 3–5 registered models (Users, Orders, Articles, Documents). That's where this package earns its keep — staff who know exactly what they're looking for, jumping across entity types in one keystroke. Resist putting it on a public marketing site first; you want to validate the authorization story internally before exposing record titles to the internet.
Sanity check before committing: bump the release date/CHANGELOG cadence against your own risk appetite. With 1 star and 0 dependents you're an early adopter — read the test workflow badges as the real signal, not the star count.
Keep the presentation layer in one place. Don't scatter globalSearchGroup()/globalSearchIcon()/globalSearchColor() across twenty models — you'll get five shades of blue and inconsistent labels. Put them on a shared base trait or abstract model:
trait SearchPresentation
{
public static function globalSearchIcon(): string
{
return match (static::class) {
\App\Models\Order::class => 'heroicon-o-shopping-cart',
\App\Models\Invoice::class => 'heroicon-o-document-currency-dollar',
default => 'heroicon-o-rectangle-stack',
};
}
public function globalSearchUrl(): string
{
return route(static::searchRouteName(), $this);
}
}
Use globalSearchBuilder() for tenancy and lifecycle, not just filters. This is the hook that makes the package production-viable in a multi-tenant app — combine it with a visibility rule, because the builder scopes the query and the rule scopes the record:
public function globalSearchBuilder(Builder $builder, string $query): Builder
{
return $builder
->where('tenant_id', auth()->user()?->tenant_id)
->where('status', '!=', 'draft');
}
public function globalSearchVisibility(): VisibilityRule
{
return VisibilityRule::make()
->policy('view')
->orRole(['admin', 'support']);
}
Prefer policies over the fluent builder when a policy already exists. Gate::check('view', $record) is the same authorization code path your controllers use — no drift. Reach for ->attribute(), ->using(), or ->mode(VisibilityMode::All) only for the awkward cases (a soft "published" flag, a compound rule).
Trim the index, not the UI. Everything the modal does is downstream of what Scout holds. toSearchableArray() should carry only searchable text plus whatever globalSearchTitle()/globalSearchSubtitle() need; use shouldBeSearchable() to keep drafts out of the index entirely so the modal never has to filter them.
Wire the preview/download contract only where it pays. HasGlobalSearchPreview with PreviewDto::fromDisk() is genuinely nice for a documents module — PDFs and images render in-pane, everything else degrades to a download button. But it's a per-record Storage call, so keep it off high-volume models and off anything on a slow disk. The download button is opt-in and fires a browser event you must handle:
window.addEventListener('scoutify:download', (e) => {
// e.detail carries the record/url — trigger your own signed download endpoint
});
Open it from anywhere — this is what makes it feel native rather than bolted on:
<button x-data @click="$dispatch('scoutify:open')">Search</button>
{{-- inside a Livewire component --}}
<button wire:click="$dispatchTo('scoutify::modal', 'scoutify:open')">Search</button>
Test with the database driver. Set SCOUT_DRIVER=database in phpunit.xml so results come from real SQL instead of an external engine, then Livewire::test(\Matheusmarnt\Scoutify\Livewire\Modal::class)->set('query', 'foo')->assertSee('...'). Add one test per registered model asserting a denied user sees nothing — that's the regression you actually care about.
Indexing at scale: for large tables, skip scoutify:import and use Scout's queueable import (scout:queue-import) so you're not holding the whole table in memory.
The scoutify:searchable command edits your source files. It inserts an import, adds implements GloballySearchable to the class declaration, and drops use Searchable; as the first statement in the body. That works on the happy path and gets fragile with unusual declarations (readonly/final classes with long implements lists, class constants before the trait, generated models). Commit first, run --dry-run, review the diff, then vendor/bin/pint. If it silently fails to add the interface, you get a model that's discovered but throws on the missing methods.
"Search returns nothing" — the checklist, in likelihood order:
authorization.default is secure: guests are denied across the board. Not a bug.scoutify:searchable to rebuild it, then php artisan config:clear.scoutify:import never ran, or ran before the model was registered.ano will not find Mariano. If users expect infix search, either configure attributesToSearchOn in globalSearchBuilder() or accept the database driver's LIKE behaviour.globalSearchBuilder() clause is too tight.Put the modal at root. This is the #1 support ticket for this package. Livewire does not initialise components inside collapsed/conditionally-rendered containers. A modal inside a collapsed sidebar simply never mounts, and the trigger looks broken with no error.
Plain Blade can't use wire:click. Outside a Livewire component tree, wire:click="$dispatch('scoutify:open')" does nothing — Livewire.js doesn't initialise those directives there. Use Alpine $dispatch or a raw window.dispatchEvent(new CustomEvent('scoutify:open')).
Accent-insensitivity is cosmetic unless your driver agrees. The highlight normalises to NFD so padrao highlights Padrão, but whether padrao matches depends on the engine: Meilisearch strips diacritics in its default tokenizer, the database driver's LIKE does not. Test with real accented content in your actual driver.
Signed preview URLs are host-sensitive. The scoutify.preview.stream route is signed. Behind a proxy, load balancer, or with a wrong APP_URL/ASSET_URL, valid previews 403. If previews work locally but not in staging, that's almost always the cause — check TrustProxies and APP_URL before you go digging in the package.
Icon prefix is global, not per-model. Scoutify::types()->iconPrefix('ri-') changes the fallback for all short icon names. If you mix icon packs, use fully-qualified names (ri-*, tabler-*, heroicon-o-*) everywhere and leave the prefix alone — fully-qualified names are matched against registered pack prefixes and passed through untouched.
Spatie integration fails closed. ->permission() / ->role() throw-style behaviour if spatie/laravel-permission isn't installed — which means a composer remove in an unrelated refactor can silently lock down your search. If you use those rules, add a test asserting an authorized user still sees results.
Session history is per-session, not per-user. It won't follow someone across devices or survive a session flush, and stored entries can hold stale URLs after a route rename. Check the config for the history length cap — long histories bloat the session cookie/payload.
Watch the hydration cost. Scout hydrates each result back into a model, then Scoutify calls globalSearchTitle()/globalSearchSubtitle()/globalSearchPreview() on each. Under Model::preventLazyLoading(), an auto-discovered subtitle from a body column or a preview backed by a file_path attribute is a real N+1 risk. Keep the per-type limit low and prefer lean toSearchableArray() payloads.
Tailwind v4 styling mismatch. The package inlines its utilities, but if the modal renders unstyled, add the package views to your Tailwind @source/content globs (vendor/matheusmarnt/scoutify/resources/views). For anything beyond that, override via the fluent theme API rather than fighting the vendor CSS.
Pin the version and treat it as vendored code. One star and zero dependents means you are the QA team. composer require matheusmarnt/scoutify:1.x.y --exact (or commit the lock file), and keep your integration thin: a single service provider registering types/visibility, one blade partial rendering the modal, and tests around the authorization matrix. If the package stalls, that structure lets you swap the component for a hand-rolled Livewire modal in an afternoon without touching twenty models.
How can I help you explore Laravel packages today?