lorisleiva/laravel-search-string
Laravel Search String parses simple, human-friendly search queries like "status:active sort:-created_at name:John" into Eloquent database queries, with support for exact matches, negation, relationships, sorting, and fully customizable syntax.
composer require lorisleiva/laravel-search-string — the service provider and SearchString facade are auto-discovered, so there is nothing to register manually. As of v1.3.0 the package supports Laravel 10, so it is safe to pull into a Laravel 10 app without a composer.json constraint override.
First three steps:
php artisan vendor:publish --tag=search-string (falls back to --provider="Lorisleiva\LaravelSearchString\LaravelSearchStringServiceProvider"). This file is where case sensitivity, stop words and allowed-field behaviour live — read it before writing any directive.SearchStringable trait to the model you want to search. That gives you a searchString() scope on the model/query builder.Post::searchString('laravel author:John -status:trashed')->get();
That single string covers the three things people actually want from this package: a free-text term (laravel), a field constraint (author:John), and a negation (-status:trashed).
The first thing to inspect when behaviour surprises you is the parsed AST, not the SQL — dump the result of SearchString::parse($input) and look at the node tree. Nine times out of ten the parser tokenised your string differently than you assumed (usually a quoting or grouping issue), and the query is fine.
Register directives in a dedicated provider. Keep SearchString::directive(...) calls out of AppServiceProvider::boot(); a small SearchStringServiceProvider (or a Search/ domain folder with a registerDirectives() method) keeps boot time and readability sane once you pass a dozen fields.
SearchString::directive('author', function ($builder, $value) {
return $builder->whereHas('author', fn ($q) => $q->where('name', 'like', "%{$value}%"));
});
SearchString::directive('status', function ($builder, $value) {
return $builder->where('status', $value);
});
Route every unscoped term through a default handler. Otherwise bare terms are ignored and the user thinks search is broken. A closure wrapping orWhere in a nested group is the standard shape:
SearchString::default(function ($builder, $value) {
$builder->where(function ($q) use ($value) {
$q->where('title', 'like', "%{$value}%")
->orWhere('body', 'like', "%{$value}%");
});
});
Scope the allowed fields per request rather than globally. Public endpoints and admin endpoints rarely share a field set. Build the search string from a whitelist (an array on the controller, a policy, or a config key) and return a 422 for unknown fields — silently ignoring -status:trashed because someone mistyped status is a security-adjacent surprise.
Compose, then apply. Put searchString() last in the chain so it sits alongside your other filters without fighting them:
$posts = Post::query()
->where('tenant_id', $tenant->id)
->when($request->filled('q'), fn ($q) => $q->searchString($request->string('q')))
->paginate();
Case-insensitive search (documented in v1.3.0). On MySQL and SQLite the default collation makes LIKE case-insensitive already; on PostgreSQL it does not. If you need consistent behaviour across engines, either use the package's case-insensitive matching option in the config/Option object, or drop to whereRaw('LOWER(name) LIKE ?', ['%'.strtolower($value).'%']) inside the directive. The v1.3.0 docs exist precisely because people were getting engine-dependent results.
Test directives, not the parser. Pest/PHPUnit datasets of search string => expected SQL or expected model set are cheap and catch regressions when Laravel's query grammar shifts on upgrade.
OR. -status:trashed must become whereNot/where('status','!=',...) inside a nested closure. If your directive ignores the negation flag, a query with any OR will return the rows you explicitly excluded.$request->filled('q') or check the parse result is non-empty before applying — otherwise a stray : or - turns into a full table scan."John Doe" is one value; John Doe is two terms. Document the quoting rules for your users (or your frontend), because this is the number-one support question.* in a search string does not become %. Translate explicitly in the directive, and never interpolate a value into whereRaw — bind it.LIKE kills indexes. %term% on a million-row table will not use a B-tree index. Past a certain size, reach for Scout/Meilisearch and use this package only to build the filter payload.whereHas, not where. A directive that does $builder->where('author', $value) on a relation column will error or return nothing; wrap in whereHas and eager-load the relation you filter on to avoid N+1.php artisan config:cache cannot serialise closures. If you put directive callbacks in a config file, they will break in production; keep them in a provider.->toSql() and bindings → only then question the parser. Also check whether another global scope is injected between your directive and execution.Option object is where you toggle case sensitivity and relationship support. Changing it globally is tempting and usually wrong — prefer per-controller instances so one noisy endpoint doesn't relax rules for all of them.How can I help you explore Laravel packages today?