hnhdigital-os/laravel-search-components
A Laravel package of reusable search components that adds filtering, sorting, and query handling to your models and Blade views, making it quick to build searchable listings without repeating boilerplate.
This package gives you a persisted, component-driven search layer: a Search record owns a set of components (fields, operators, values), and a trait on your Eloquent models turns those components into a query. It is a saved-search/query-builder abstraction, not a full-text engine — think "user-defined filters stored in the DB and re-runnable."
Minimal setup:
composer require hnhdigital-os/laravel-search-components
php artisan vendor:publish --provider="HnHDigital\LaravelSearchComponents\ServiceProvider"
php artisan migrate
Where to look first, in this order:
config/search.php — model bindings and table names. Everything is swappable here; check it before assuming class names.src/Models/Search.php and the component model — this is the schema you are actually persisting.Searchable trait (applied to the model you want to search) — this is the contract you implement.tests/ if shipped — fastest way to see a working component array.First use case: add the trait to one model, define two or three searchable fields, create a Search with a couple of components, and dump the resulting query with ->toSql() before you wire any UI.
use HnHDigital\LaravelSearchComponents\Traits\Searchable;
class Order extends Model
{
use Searchable;
protected $searchable = ['reference', 'status', 'created_at'];
}
$search = Search::create(['name' => 'Overdue orders', 'user_id' => auth()->id()]);
$search->components()->createMany([
['field' => 'status', 'operator' => '=', 'value' => 'pending'],
['field' => 'created_at', 'operator' => '<', 'value' => now()->subDays(30)],
]);
$results = Order::search($search)->paginate(25);
Verify the method name (search, applySearch, scope vs. static) against the trait source — this package has changed its public surface across majors and the README lags.
Persist, don't inline. The value of the package is that a search is a row, not a controller method. Treat Search as an aggregate: name, owner, components, and optionally a is_public/shared flag. That gives you saved searches, shareable filters, and scheduled "email me these results" for free.
Two-layer creation. Create searches from a form request that validates components as an array (field in the allowed set, operator in the allowed set, value present), then map to component rows in a service or action class. Never let the raw request reach the trait — an unvalidated field is a SQL injection or at minimum an Unknown column 500.
class StoreSearchRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'components' => ['required', 'array', 'min:1'],
'components.*.field' => ['required', Rule::in(Order::searchableFields())],
'components.*.operator'=> ['required', Rule::in(['=', '!=', '>', '<', '>=', '<=', 'like', 'in'])],
'components.*.value' => ['required'],
];
}
}
Whitelist per model, not globally. Keep the $searchable array next to the model. It doubles as your validation source and your UI field list, so the form and the query builder can't drift apart.
Compose with existing scopes. Apply the package's filter on top of your normal authorization and soft-delete scopes, not the other way round:
Order::query()
->where('tenant_id', $tenant->id) // tenancy first
->search($search) // components second
->with('customer') // eager loads last
->paginate(25);
Clone before reusing. If the builder returned mutates state (many query-builder packages do), clone it when you need both a count() and the paginated result set.
API surface. Return components as a resource with field/operator/value and let the frontend rebuild the same payload it sent. Round-tripping the same shape in and out keeps the UI trivial and makes "edit saved search" a PATCH.
Testing. Build searches with factory() or direct create(), then assert on ->toSql() for the generated shape and on the result set for behaviour. Test the empty-component case explicitly — it's the one that silently returns everything.
3.0.2 is a patch with an opaque changelog. The release notes are a bare compare link with no described changes. Pin 3.0.1 if you need certainty and diff the two tags manually before upgrading production; patch releases on this package have historically included trait and namespace touches that can affect overridden classes.
Config cache trap. Class names are read from config. If you swap the Search model to your own subclass and forget php artisan config:cache (or forget to clear it after deploying), you'll get "class not found" or, worse, the package's base model writing to a table your app doesn't have. Put config:clear in the deploy script.
Swap models by extending, not editing. The supported extension point is your own model extending the package's, bound in config/search.php. Never edit published migrations in place once they've run in a shared environment — publish again into a new migration.
Empty component set = no filtering. Unless the trait guards it, a Search with zero components returns the whole table. Guard in the trait wrapper or throw before executing, and never treat an empty search as "show everything" in a UI with pagination — someone will page through 400k rows.
Operator strings are not validated by the package. They are concatenated into SQL. Anything user-supplied that isn't in a strict allow-list is your vulnerability, not the package's.
like is your problem. LIKE '%term%' cannot use an index. For reference or name fields on large tables, either switch to a =/prefix operator or pair the component with a dedicated index/LIKE 'term%'.
Debugging generated queries. DB::listen() or ->dd() on the builder is more reliable than reading the trait; the component loop makes the SQL hard to predict from the input. Log toSql() plus getBindings() when a filter "doesn't work" — it's usually a component that silently matched nothing.
Date and cast handling. Values stored in the component table come back as strings. A Carbon instance you assign may or may not survive the round-trip depending on casts in the package model. Cast explicitly and test a date operator end to end.
Trait collisions. Both this trait and common packages (Searchable from Laravel Scout, HasSearch style traits elsewhere) may define conflicting method names. If Scout is installed, alias one trait (use Searchable { ... as ... }) — Searchable is an overloaded name and a silent collision here produces confusing method resolution, not an error.
Polymorphic scope. If you store a searchable_type per search, keep FQCNs stable. Renaming a model class (or moving it to App\Models\) orphans saved searches with no error until someone runs them.
Extension points worth knowing. Config model bindings, the component model's casts/attributes, and the trait's field accessor are the three places to override. Anything deeper means a fork — wrap the trait behind a small service in your own app so a future major version doesn't ripple through your controllers.
How can I help you explore Laravel packages today?