jhormantasayco/laravel-searchzy
Laravel Searchzy is a lightweight package that adds simple, chainable search to your Eloquent models. Filter records by multiple columns, handle relationships and custom queries, and keep your controllers clean without pulling in a full search engine.
Install it like any other small utility package:
composer require jhormantasayco/laravel-searchzy
The service provider is auto-discovered on the Laravel versions the package targeted (roughly 7/8). Before you run this in a modern app, sanity-check the dependency graph — the last release was Feb 2021, so composer why-not jhormantasayco/laravel-searchzy is your first command if install fails on Laravel 9+.
The public surface is intentionally tiny: add the trait to the model you want to filter, declare which columns are searchable, and call the scope from your controller.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Jhormantasayco\LaravelSearchzy\Traits\Searchzy;
class Post extends Model
{
use Searchzy;
// column => operator. This is your whitelist.
protected $searchable = [
'title' => 'like',
'body' => 'like',
'status' => '=',
'user_id' => '=',
];
}
Your first real use case will almost always be an index page with a search box plus a couple of dropdown filters:
public function index(Request $request)
{
$posts = Post::searchzy($request->only(['q', 'status', 'user_id']))
->latest()
->paginate(20)
->withQueryString();
return view('posts.index', compact('posts'));
}
Where to look first, in order: the $searchable examples in the README, the trait source (vendor/jhormantasayco/laravel-searchzy/src), and then the generated SQL. That last step matters more than the docs — the package is small enough to read end-to-end in ten minutes, and you will need to know exactly how it groups conditions.
Validate the filter keys, not just the values. The scope consumes an array keyed by column name. Treat that array as untrusted input and shape it in a FormRequest or a dedicated PostFilters object before it reaches Eloquent:
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', Rule::in(['draft', 'published'])],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
]);
$posts = Post::searchzy(array_filter($filters))->paginate(20);
Keep the scope in the model, keep the policy in the controller. Combine it with authorization and tenant scoping rather than replacing them:
$posts = Post::query()
->where('team_id', $request->user()->team_id) // tenancy first
->searchzy($filters) // then user filters
->with('author:id,name') // eager load what you render
->paginate(20);
Mirror the same form between web and API. Because the scope takes a plain array, both a Blade form POST and a JSON GET /api/posts?q=... can feed the same filter object. Build one PostFilters::fromRequest() and reuse it.
Blade partial for the search form. Preserve submitted values so the box doesn't clear on pagination:
<form method="GET" action="{{ route('posts.index') }}">
<input name="q" value="{{ request('q') }}" placeholder="Search posts...">
<select name="status">
<option value="">All</option>
@foreach (['draft', 'published'] as $s)
<option value="{{ $s }}" @selected(request('status') === $s)>{{ $s }}</option>
@endforeach
</select>
<button type="submit">Filter</button>
</form>
Wrap it in a local scope for team conventions. If you standardise on q for the free-text term, translate once instead of in every controller:
public function scopeFiltered(Builder $query, array $input): Builder
{
return $query->searchzy([
'q' => $input['q'] ?? null,
'status' => $input['status'] ?? null,
]);
}
Test the SQL, not the package. Assert that a filtered request returns the right rows and that an unauthorised column in the input is ignored — that is the behaviour you actually care about.
$searchable, any key the user sends becomes a WHERE. Always verify $searchable is enforced; if it isn't, whitelist in your controller with array_intersect_key($input, array_flip(['q', 'status', 'user_id'])).LIKE with empty input. An empty string often becomes LIKE '%%', which is a no-op but still a scan. Strip nulls and empty strings before calling the scope (array_filter($filters, 'filled')).% and _ typed by a user are treated as SQL wildcards. If that matters, escape them or switch the column to = and use a separate prefix search.LIKE case-insensitive; Postgres does not. Don't assume parity between local SQLite and production Postgres when writing tests.orWhere grouping. Simple filter packages frequently emit WHERE a = ? OR b = ? without wrapping parentheses, which breaks the moment you add a where('team_id', ...) alongside it. Always ->toSql() a combined query once and read it.author.name via dot notation is a common expectation and a common absence. If relations aren't supported, use whereHas manually rather than fighting the scope.with() for anything you render in a loop.LIKE '%term%' cannot use a B-tree index. For tables beyond a few thousand rows, plan on a FULLTEXT/tsvector index or a dedicated search driver instead of stretching this package.spatie/laravel-query-builder for richer filtering, or a 40-line local scope if your needs are as simple as they look. The trait is small enough to vendor into app/Support if the package becomes uninstallable.How can I help you explore Laravel packages today?