Most admin panels do not need Elasticsearch to search their lists. They need a search that finds "acme sitges" when the company is Acme and the location is Sitges, that finds "Camión" when you type "camion", and that never shows a user a record they are not allowed to open.
I had a HasSearchText trait copied across six projects, each copy a little different. Larasearch is that trait done once, with what the best copy did and what none of them got right.
Install
It is a regular Laravel package. Require it and run the migration, which creates the table for the global index:
composer require edulazaro/larasearch
php artisan migrate
There is no config file.
Searching a list
Each model keeps its own search_text column. Add it to the table:
Schema::table('projects', function (Blueprint $table) {
$table->searchable(); // search_text, and a FULLTEXT index on MySQL
});
Then use the trait and name the fields that go into it:
use EduLazaro\Larasearch\Concerns\HasSearch;
class Project extends Model
{
use HasSearch;
protected array $searchable = ['event_name', 'company', 'client_name', 'email', 'location'];
}
And search inside any query:
Project::visibleTo($user)
->searchText($request->q)
->where('status', 'confirmed')
->paginate(25);
searchText() is a scope, so it combines with your own scopes, filters and pagination. An empty term leaves the query alone, so a filter can always pass it on without an if.
What a match means
The rules are the same for every model, so a term finds the same records on every screen:
- Every word must appear, in any order and in any field: "acme sitges" finds company Acme, location Sitges.
- Text is stored and searched the same way, lower case and without accents: "camion" finds "Camión".
- LIKE wildcards are escaped: "50%" means fifty per cent, not "50 followed by anything".
On MySQL, whole words of three letters or more go through the FULLTEXT index and match from their start, so "sitg" finds "sitges". Short words, emails and MySQL's stopwords go through LIKE, because the index does not store them and a required word it cannot find returns nothing. "forza horizon 6" still finds Forza Horizon 6.
When the column is written
It is written in the same save as the model, and only when one of the fields changed. Saving a project whose status alone changed costs nothing extra.
It is also written after every saving observer. If an observer fills in a normalized phone on saving, the phone is in the text. Getting this right took two attempts: the first release relied on the order Laravel attaches listeners, and PHP 8.5 changed that order. The text is now written on creating and updating, which Eloquent always fires after every saving listener.
Fields of related models
Searching a project by the name of one of its tasks is a common ask. A field with a dot is read through a relation:
protected array $searchable = ['name', 'client', 'tasks.title', 'owner.name'];
When a task is saved or deleted, or the owner renamed, the project is indexed again. Searchable models in app/Models are found on their own, so this works even when the request or queued job that saves the task never loaded a project.
The global index
A command palette searches everything at once, so it needs a different shape: one table with a row per record. A model joins it when it says how to show itself and who may see it:
protected string $searchableTitle = 'event_name';
protected ?string $searchableSubtitle = 'company';
protected string $searchableRoute = 'projects.show';
public static function searchableFor(User $user): Builder
{
return static::visibleTo($user);
}
Searching it reads like the lists:
use EduLazaro\Larasearch\Models\Index;
Index::searchText('acme')
->in($organization)
->for($user)
->limit(10)
->get();
Each row holds the title and subtitle, so the palette paints results without loading the records. The link is built when read, from the route and the key, so a renamed route leaves nothing stale.
Who sees what
This is the part that is easy to get wrong in a global index, so it is not optional. for($user) asks each model's searchableFor() at search time:
// A task is seen by whoever sees its project.
public static function searchableFor(User $user): Builder
{
return static::whereIn('project_id', Project::visibleTo($user)->select('id'));
}
Visibility is never stored in the index. Reassigning a project to someone else removes it from the previous person's palette at once, with no reindex. A model in the index without searchableFor() throws an exception instead of showing everything to everyone.
Tenants
Multi-tenant applications need each tenant's rows kept apart before anything else. Say how to find a model's tenant once:
Larasearch::resolveScopeUsing(fn (Model $model) => $model->organization);
Then in($organization) keeps one tenant's rows, in($agency->offices) several. It is explicit on purpose: which tenant a query reads should be visible in the code.
What it cannot catch
Some changes never go through Eloquent: a query builder update(), an import, a pivot attach(). They fire no event, so after them rebuild the text:
php artisan search:reindex
It writes every record again in batches and removes index rows whose record no longer exists.
Links
👉 Source: https://github.com/edulazaro/larasearch
👉 Packagist: https://packagist.org/packages/edulazaro/larasearch
👉 Documentation: https://edulazaro.com/portfolio/larasearch
Top comments (0)