Open source October 2026
Larasearch

Larasearch

Larasearch

Larasearch is text search for Eloquent models on the application's own database: no Elasticsearch, no Meilisearch, no service to run. It came out of the same HasSearchText trait copied across six projects, each copy a little different, and collects what the best of them did plus what none of them got right.

It has two uses, through one trait:

  • Lists. Each model keeps a normalized search_text column built from the fields you name, and searchText($term) filters on it inside any query.
  • A global index, for command palettes. Models that say how to show themselves keep a row in one searchables table, scoped by tenant and filtered, at search time, by what each user may see.

Installation

composer require edulazaro/larasearch
php artisan migrate

The migration creates the searchables table. There is no config file.

Searching a model's lists

Add the column to the model's table, use the trait and name the fields:

Schema::table('projects', function (Blueprint $table) {
    $table->searchable();   // search_text, and its FULLTEXT index on MySQL
});
use EduLazaro\Larasearch\Concerns\HasSearch;

class Project extends Model
{
    use HasSearch;

    protected array $searchable = ['event_name', 'company', 'client_name', 'email'];
}
Project::visibleTo($user)
    ->searchText($request->q)
    ->where('status', 'confirmed')
    ->paginate(25);

Every word of the term must appear, in any order and in any field: "acme sitges" finds the project whose company is Acme and whose location is Sitges. Text is stored and searched the same way, lower case and without accents, so "camion" finds "Camión". LIKE wildcards are escaped, so "50%" means fifty per cent. An empty term leaves the query alone, so a filter can always pass it on.

The column is written in the same save, only when one of the fields changed, and after every saving observer, so a value an observer fills in (a normalized phone, say) is in the text. A table without the column fails with a message naming the migration to add.

Fields of related models

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 the queued job that saves the task never touched a project.

The global index

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);
}
use EduLazaro\Larasearch\Models\Index;

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

Each row holds the title and subtitle, so a palette paints results without loading the records, and the link is built when read from the route. Visibility is never stored: for($user) asks each model's searchableFor() at search time, so reassigning a record removes it from the previous person's palette at once. A model in the index without searchableFor() fails loudly rather than showing everything to everyone.

For multi-tenant applications, each row carries its tenant, set once with Larasearch::resolveScopeUsing(fn ($model) => $model->organization), and in() keeps one tenant's rows, several, or none.

Keeping it up to date

Saving, deleting and restoring keep the column and the index row current. Changes made around Eloquent fire no event, a query builder update(), an import, a pivot attach(), so after them:

php artisan search:reindex

How the words are matched

On MySQL, whole words of three letters or more go through a FULLTEXT index and match from their start ("sitg" finds "sitges"). Shorter words, emails and MySQL's stopwords go through LIKE, because the index does not store them and a required word it cannot find would turn every search into "nothing found". On SQLite and PostgreSQL every word goes through LIKE.

built and maintained by Edu Lazaro · MIT license