liberusoftware/search
A Laravel search package from Liberu Software that adds flexible, driver-based search to your application. Index and query Eloquent models through a simple, consistent API, with support for multiple search backends and easy configuration.
Confirm the install name first. The repository is liberusoftware/module-search but the Composer package is listed as liberusoftware/search. In a package like this the repo name and the Packagist name frequently diverge (module repos often ship a differently-named package). Check composer.json in the repo root for the name field before running anything, then:
composer require liberusoftware/search
If it is not on Packagist, add it as a VCS repository:
"repositories": [{ "type": "vcs", "url": "https://github.com/liberusoftware/module-search" }]
Where to look first, in this order:
composer.json — real package name, extra.laravel.providers, PHP/Laravel constraints, autoload namespace (likely Liberu\Search\ or Liberusoftware\Search\).config/ — the "replaceable model configuration" almost certainly lives here. This is the single most important file; it tells you which models are searchable and which class the package resolves for each.src/ — look for a trait to add to models (e.g. Searchable), a query builder / search request object, and filter classes.src/Http/ or routes/ — if it ships a controller or route macro, note whether it registers routes automatically or only provides building blocks.Publish the config and read it end to end before writing code:
php artisan vendor:publish --provider="Liberu\Search\SearchServiceProvider"
First use case. The package's value proposition is turning a hand-rolled index() action — request parsing, filter whitelisting, authorization, pagination — into a declarative pipeline. Take one existing listing endpoint, e.g. GET /api/orders?q=acme&filter[status]=paid&sort=-created_at&per_page=25, and reproduce it with the package. Do not refactor every index action at once; get one endpoint behaviourally identical (same JSON shape, same pagination meta) and diff the responses.
Controller as a thin adapter. Keep the controller responsible only for: resolving the searchable model, passing the request payload, authorizing, and returning a resource collection. Everything else should come from the package.
public function index(Request $request)
{
$results = Order::search($request->all())
->authorize($request->user()) // or ->authorizedFor($user)
->paginate($request->integer('per_page', 25));
return OrderResource::collection($results);
}
Verify the actual method names against src/ — the shape above is the pattern this class of package converges on (a static/trait entry point, an authorization hook, then a paginator).
Treat filters as a whitelist, declared on the model or in config. The most reliable pattern is to declare allowed filters next to the searchable model rather than trusting request keys:
protected array $searchable = ['reference', 'customer_name'];
protected array $filterable = ['status', 'currency', 'created_at'];
protected array $sortable = ['created_at', 'total'];
If the package instead asks for a filter map in config/search.php, put it there and keep the model clean. Either way the request should never be able to name a column the developer did not name first.
Authorization before execution, not after. The advertised "authorized search queries" behaviour is the reason to use this over a generic filter package. Two patterns are typical and worth checking for:
viewAny / a dedicated search ability) consulted once before the query runs. Cheapest, no row-level leakage if your policy is tenant-aware.where('tenant_id', $user->tenant_id)) so pagination totals are correct. This is the one you want for multi-tenant apps — a post-hoc filter() on results breaks total() and page counts.Test this explicitly: as user A, request a record belonging to tenant B by ID via filter[id]=... and confirm you get zero results, not a 403 (a 403 tells you the row exists).
Replaceable model configuration is an extension seam. The intent is that the package resolves models through the container/config rather than hardcoding class names. Two practical uses:
Order) at a denormalised projection table (order_search_index) when joins get slow, without touching controllers.Bind your replacement in a service provider after the package's own provider is registered, and confirm the package takes the binding from the container rather than new-ing the class (grep src/ for app(/resolve( versus direct instantiation — if it's the latter, the "replaceable" promise doesn't hold and you'll be forking).
Reuse the pipeline outside HTTP. The valuable half of this package is request→query translation. Call it from a console command (nightly export), a queued job (saved searches, alerts), or an internal Livewire/Inertia filter panel. Building the payload array by hand beats duplicating filter logic per surface.
Saved searches / shareable URLs. Because filters, sort, and page all live in the query string, persisted search state is free: store the validated query string and replay it. Add a per_page cap in the controller before it reaches the package.
Zero stars, zero dependents, no issues history. Treat this as internal-grade code. Before adopting: read every file in src/ (it is almost certainly small), pin an exact version in composer.json, and commit a composer.lock. If it's central to your app, consider forking into your own namespace so an abandoned upstream doesn't block a Laravel major upgrade. The MIT licence makes that trivial.
Watch the release date oddity. The listed last release is 2026-10-27 — a future date usually means the tag metadata is off, or the release is a placeholder. Confirm the tag actually resolves (composer show liberusoftware/search --all) and that the tagged commit matches the branch you reviewed.
Column-name injection. The classic failure mode for filter/sort packages is passing request keys straight into orderBy() / whereColumn(). If the package doesn't validate sort against a whitelist, add that yourself in a middleware or form request:
$request->validate([
'sort' => ['nullable', 'regex:/^-?[a-z_]+$/'],
'filter.*' => ['nullable', 'string', 'max:255'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
Relation filters and N+1. Filtering on a related column (e.g. filter[customer.name]) usually compiles to a whereHas, which is fine for filtering but silently drops the eager load. Pair with ->with() explicitly, or the resource layer will trigger a query per row. Check whether the package exposes a hook for eager loads — if not, wrap the paginator with ->through(fn ($m) => $m->load(...)).
Counts are the expensive part. paginate() runs a COUNT(*) on a query that may carry joins and whereHas. For large tables switch to simplePaginate() (no total, "next/previous" only) or have the package return a cached/approximate total. Also ensure per_page is capped server-side — an uncapped per_page=100000 is a one-line DoS.
Config caching. If you add searchable models to config, remember php artisan config:cache in deployment. New searchable models that "work locally but not in production" are almost always a stale config cache.
Authorization bypass through the wrong entry point. If the package offers both a raw query builder and an authorized one, grep the codebase for the raw one — it's easy for a teammate to call the unguarded variant in a new controller. Consider a static-analysis rule or a base controller that only exposes the authorized path.
Debugging. Log the compiled SQL plus bindings at the boundary (DB::listen gated behind a local-only flag). When a search returns nothing, the cause is usually one of: filter key not in the whitelist (silently ignored), authorization scope narrowing to empty, or the q term not being applied to any column because the model's searchable field list is empty.
Extension points to look for (and to add if missing). Custom filter classes for domain logic (date ranges, enum sets, "my records only"), a macro or callback hook before the query executes, and a resource/transformer hook for output shaping. If none exist, wrap the package in your own thin SearchService so the rest of the app depends on your interface, not on the vendor's — that makes replacing or forking it a one-file change.
How can I help you explore Laravel packages today?