| name | searchable-development |
| description | Add multi-column database search to Eloquent models using mozex/laravel-searchable. Activate when the user mentions Searchable trait, searchableColumns, advancedSearchable, SearchableGlobalSearchProvider, applySearch, multi-column search, relation search, morph search, cross-database search, relevance ordering, orderByRelevance, ranking search results, or uses ->search() on Eloquent queries. Also activate when adding search to a model, configuring Filament table search, or setting up Filament global search with this package. Covers column notation (direct, relation, morph, external), column filtering (in/include/except), relevance ordering, Laravel Scout coexistence, and resolving conflicts when another package owns the search method name. |
Searchable Development
When to use this skill
Activate when the user works with mozex/laravel-searchable, the Searchable trait, searchableColumns(), ->search() scope, applySearch(), advancedSearchable() Filament macro, or SearchableGlobalSearchProvider. Also activate when adding search to an Eloquent model that uses this package.
The Searchable Trait
Add Mozex\Searchable\Searchable to any Eloquent model and define searchableColumns(). You can mix direct columns, relation columns, and morph relations in the same array:
use Mozex\Searchable\Searchable;
class Comment extends Model
{
use Searchable;
public function searchableColumns(): array
{
return [
'body',
'author.name',
'tags.name',
'commentable:post.title',
'commentable:video.name',
];
}
}
Then search:
Comment::search('term')->get();
Comment::query()->where('published', true)->search('term')->paginate();
Column Notation
Five column types, detected automatically from the string format:
| Type | Format | Example | How it searches |
|---|
| Direct | column | 'title' | orWhereLike on the column |
| Relation | relation.column | 'author.name' | orWhereHas with whereLike |
| Morph | relation:type.column | 'commentable:post.title' | orWhereHasMorph |
| External | relation.column (different DB) | 'product.name' | orWhereIn with subquery (default cap of 50 IDs) |
| External morph | relation:type.column (different DB) | 'commentable:product.name' | type check + whereIn subquery (same cap) |
External relations are auto-detected when the related model's $connection differs from the current model's. Only BelongsTo relations are detected as external (HasMany on a different connection falls through to regular relation search).
Nested morph relations work: 'commentable:post.author.name' resolves the morph to Post, then follows the author relation.
A MorphTo MUST use the typed colon syntax. Writing it as plain dot notation ('commentable.title') can't resolve to a single model, so the package skips that column instead of erroring. Use 'commentable:post.title'.
Case Sensitivity
All search types use Laravel's whereLike, so matching is case-insensitive by default. Post::search('LARAVEL') matches laravel, Laravel, and LARAVEL with no extra flag.
The actual behavior is database-driven:
- MySQL / MariaDB: case-insensitive on
_ci collations (default), case-sensitive on _bin / _cs.
- PostgreSQL: always case-insensitive (
whereLike compiles to ILIKE).
- SQLite: case-insensitive for ASCII only.
The package exposes no flag to flip this. To force case-sensitive matching, change the column's collation at the database level.
Column Filtering Parameters
Override or adjust columns per-query:
Post::search('term', in: ['title', 'body'])->get();
Post::search('term', include: ['slug'])->get();
Post::search('term', except: ['author.name'])->get();
Post::search('term', in: 'title')->get();
Adjusting the Cross-Database Cap
External relation and external morph columns run a subquery on the other connection and feed matching IDs into a whereIn. The subquery caps results at 50 by default to keep the IN (...) clause sane. Override per-query with externalLimit:
Post::search('term', externalLimit: 200)->get();
$model->applySearch($query, 'term', externalLimit: 200);
TextColumn::make('title')->advancedSearchable(externalLimit: 200);
The parameter is ignored when no external columns are involved.
Relevance Ordering
Results are ranked by match relevance automatically. The searchableColumns() array order is the priority: a match in an earlier column outranks a match found only in a later column, so put your most important column (usually title or name) first. Within a column, exact matches rank above prefix matches above buried substring matches.
The grading depends on the column type:
- Direct, same-connection relation, and direct-target morph columns get full exact/prefix/substring scoring.
- HasMany relations are scored by their best-matching child row.
- Cross-database relations and two-hop morph columns (e.g.
commentable:post.author.name) get a matched-or-not score, but still respect column priority.
Toggle with orderByRelevance (default true):
Post::search('term', orderByRelevance: false)->get();
$model->applySearch($query, 'term', orderByRelevance: false);
The trait adds one ORDER BY key per searchable column, after the WHERE. An orderBy() added BEFORE search() stays the primary sort and relevance becomes the tiebreaker; added AFTER search(), relevance leads and your column breaks ties.
applyRelevanceSort($query, $search, in/include/except, externalLimit) applies just the ordering (no WHERE) for callers that filter separately.
Filament tables: ranking is automatic
Filament tables rank by relevance automatically while searching. No per-table code. When the package boots with Filament present, RelevanceSort::register() adds a global table query scope (via Table::configureUsing + modifyQueryUsing) that runs on the outer query before sorting.
It composes instead of replacing:
- Not searching: does nothing, the table's own
defaultSort runs untouched.
- Searching: relevance leads, the table's
defaultSort becomes the tiebreaker.
- User clicks a sortable column: relevance steps aside, their sort wins.
It applies only to models that use the Searchable trait. Note the macro can't rank on its own (Filament runs the search callback inside a nested WHERE, and Eloquent discards any orderBy there), which is why ranking rides a query scope instead.
Global opt-out, then wire manually if you want:
use Mozex\Searchable\Filament\RelevanceSort;
RelevanceSort::$enabled = false;
RelevanceSort::apply($query, $search, $sortColumn);
Filament Integration
Table Column Macro
When Filament is installed, advancedSearchable() is available on TextColumn. Add it to ONE column; it searches all configured searchableColumns():
TextColumn::make('title')->advancedSearchable()->sortable(),
TextColumn::make('title')->advancedSearchable(except: ['author.name']),
TextColumn::make('title')->advancedSearchable(method: 'databaseSearch'),
Global Search Provider
Register the provider on the panel:
use Mozex\Searchable\Filament\SearchableGlobalSearchProvider;
return $panel->globalSearch(SearchableGlobalSearchProvider::class);
The provider passes each resource's getGloballySearchableAttributes() as the in: filter to the model's search scope. Resources can either return all of the model's columns or a subset:
public static function getGloballySearchableAttributes(): array
{
return (new Course)->searchableColumns();
}
public static function getGloballySearchableAttributes(): array
{
return ['title', 'author.name'];
}
Each resource you want in global search MUST define getGloballySearchableAttributes(). Resources without it are excluded from global search entirely. Resources whose models don't use the Searchable trait fall through to Filament's default global search.
Laravel Scout Coexistence
Scout adds a static Post::search() method; this package adds a query scope. Different call paths, so they don't collide.
In practice, having two search entry points on the same model is confusing. The cleaner pattern is to alias this package's scope to a different name with PHP's trait aliasing:
use Laravel\Scout\Searchable;
use Mozex\Searchable\Searchable as DatabaseSearchable;
class Lesson extends Model
{
use DatabaseSearchable {
scopeSearch as scopeDatabaseSearch;
}
use Searchable;
}
Now Lesson::search('term') runs Scout, Lesson::databaseSearch('term') runs this package. For the Filament macro, pass the renamed method via advancedSearchable(method: 'databaseSearch').
Existing search Methods on Builder or Parent
Two cases where $query->search() won't reach this package's scope:
- Custom Eloquent Builder owns
search() (Corcel's PostBuilder is the textbook example). The Builder's method wins.
- Parent model already declares
scopeSearch with a different signature. PHP throws a fatal error when the trait is added because trait methods must be signature-compatible with inherited methods.
For both cases, use applySearch() to invoke the scope without going through the search name:
$query = Product::query();
$query->getModel()->applySearch($query, 'term', in: ['title']);
$results = $query->get();
For the parent-model signature conflict, alias the scope when adding the trait (same pattern as the Scout case):
use Mozex\Searchable\Searchable as DatabaseSearchable;
class Product extends VendorModel
{
use DatabaseSearchable {
scopeSearch as scopeDatabaseSearch;
}
}
For the Builder case, you can also delegate back to applySearch from the Builder's search() so the rest of the codebase keeps calling $query->search():
class ProductBuilder extends \Corcel\Model\Builder\PostBuilder
{
public function search($term = false, ...$args): self
{
$query = Product::query();
(new Product)->applySearch($query, $term, ...$args);
return $query;
}
}
Common Patterns
Dynamic column composition from related models
public function searchableColumns(): array
{
return [
'name',
'slug',
...collect((new Faq)->searchableColumns())
->map(fn (string $column): string => 'faqs.' . $column)
->toArray(),
];
}
Scoping search in Livewire/controllers
$results = auth()->user()
->projects()
->orderByDesc('updated_at')
->search($this->search, except: ['user.name', 'user.email'])
->paginate();
Put orderByDesc('updated_at') before search() so the recency sort stays primary. Call it after search() instead, and relevance leads with updated_at as the tiebreaker.