DEV Community

Cover image for Larasearch: text search in Laravel for Eloquent
Eduardo Lázaro
Eduardo Lázaro

Posted on

Larasearch: text search in Laravel for Eloquent

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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'];
}
Enter fullscreen mode Exit fullscreen mode

And search inside any query:

Project::visibleTo($user)
    ->searchText($request->q)
    ->where('status', 'confirmed')
    ->paginate(25);
Enter fullscreen mode Exit fullscreen mode

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'];
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Searching it reads like the lists:

use EduLazaro\Larasearch\Models\Index;

Index::searchText('acme')
    ->in($organization)
    ->for($user)
    ->limit(10)
    ->get();
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)