liberusoftware/search-api
A Laravel package that provides a fluent API for building and running search queries, letting you define searchable models, apply filters and sorting, and return paginated results through a clean, consistent interface.
This module is the HTTP skin over a search backend — it does not implement search itself. Expect authenticated, rate-limited routes that accept a query and delegate to liberusoftware/search (or whatever engine you bind). That means setup is two jobs: install the transport, then make sure there is something behind it.
Before you composer require, do five minutes of due diligence, because the public signal is thin (0 stars, 0 dependents, no downstream projects to crib from):
composer show liberusoftware/search-api --all # requires, suggests, versions
# on GitHub: read composer.json requires, CHANGELOG, and src/Http/Controllers
Then:
composer require liberusoftware/search-api
php artisan vendor:publish --provider="Liberu\SearchApi\SearchApiServiceProvider"
php artisan route:list --path=search
route:list is your map. Read it before writing any client code — it tells you the URI prefix, the middleware stack, and the exact parameter names. Do not assume /api/search; the module may mount under a configurable prefix or version segment.
Auth is the first decision. If your consumers are SPAs, check whether the routes ship with auth:sanctum (stateful cookie flow) or auth:api (bearer tokens). If they're server-to-server, mint a token with a narrow ability and confirm the middleware actually checks it:
$token = $user->createToken('search-client', ['search:read'])->plainTextToken;
Rate limiting is the second. Find the limiter the module registers — typically in its service provider via RateLimiter::for('search-api', fn ($request) => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())) — and confirm the cache store backing it isn't array.
First use case: get one query through end-to-end from a real client, including the failure paths.
curl -s -H "Authorization: Bearer $TOKEN" \
"https://app.test/api/search?q=invoice&per_page=10" | jq
If that returns 401, 429, or 500, you have found the three things you'll actually spend your first day on. Fix those before building features.
Treat it as a bounded context, not a controller you edit. The highest-value pattern is to leave the vendor routes alone and add a thin façade in your app that owns your public contract:
// routes/api.php
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:search-api'])->group(function () {
Route::get('/search', SearchProxyController::class);
});
The proxy forwards to the module (via HTTP client or by resolving the module's service from the container) and reshapes the response. This buys you versioning, response stability, and the ability to swap the module out later without breaking clients.
Normalize the response into a resource. Search APIs drift on pagination shape. Pin it:
return SearchResultResource::collection($result->hits())->additional([
'meta' => [
'total' => $result->total(),
'page' => $result->page(),
'per_page' => $result->perPage(),
'took_ms' => $result->took(),
],
]);
Scope by tenant, always. For multi-tenant apps the classic bug is a search endpoint that silently searches everything. Either the module supports a filter scope, or you wrap it:
// middleware or controller
$query = $request->string('q');
$filters = ['tenant_id' => $request->user()->tenant_id];
Never let a client-supplied filter[tenant_id] override the server-derived one. Merge server-side after the client payload.
Handle indexing as an event-driven concern. The read API is only as good as the write path. Wire model observers to dispatch index/update/delete jobs onto a queue, and keep them idempotent — reindex jobs get retried. Run a full reindex as a scheduled command, not as a request.
Cache at the edge of the search call, keyed by the normalized query. Search is expensive and often repeated:
$key = 'search:'.hash('xxh128', json_encode([$tenantId, $q, $filters, $page]));
$result = Cache::remember($key, now()->addMinutes(5), fn () => $engine->search(...));
Invalidate by bumping a per-tenant version key on write rather than tagging (tags aren't supported on all stores, and file/database stores silently ignore them).
Testing: fake the limiter, not the auth. Use Sanctum::actingAs($user) and clear limiter state between tests, otherwise your second test in a file gets a 429 and you lose an hour:
protected function setUp(): void
{
parent::setUp();
RateLimiter::clear('search-api');
}
Add one contract test that asserts the shape of the response (keys, types, pagination fields). That's the test that survives a vendor upgrade.
Version before you ship. If you expose this to third parties, the module's route prefix is your API version. Confirm it's settable via config so you're not re-mounting routes to introduce /v2.
"liberusoftware/search-api": "1.2.3"), read CHANGELOG.md before every bump, and assume no Stack Overflow answers exist. Budget time to read src/.config() inside a route file, php artisan config:cache in production can freeze a value you changed. Verify with php artisan route:list after caching.array in local/testing, throttling appears to work and then vanishes; if it's file behind multiple app servers, each node gets its own counter. Use Redis for anything multi-node, and confirm Retry-After and X-RateLimit-* headers are actually emitted.auth:sanctum in a route group. Double application is harmless for auth but will double-count rate limits if throttle is also stacked. Inspect the middleware on route:list before adding yours.EnsureFrontendRequestsAreStateful and correct SANCTUM_STATEFUL_DOMAINS. A 419 on a search call is this, not the module.404 vs empty result set. Decide the contract explicitly. A no-hits search should be 200 with an empty data array; a malformed query should be 422. If the module returns 404, normalize it in your proxy — clients will not handle it gracefully.per_page caps and max offset/from limits should be enforced server-side. If the module doesn't cap them, clamp in your middleware or you'll hand clients a one-request way to take down the engine.q straight to a query DSL (Elasticsearch/Meilisearch), validate and length-limit it. Reject arrays where a string is expected — ?q[]=x producing a 500 is a common finding.bootstrap/app.php (Laravel 11+) or the exception handler uses shouldRenderJsonWhen for api/* or the module's prefix, otherwise 500s come back as HTML and your HTTP client throws something useless.config/search-api.php, a SearchApi facade, response resources you can override, and middleware you can insert. If the controller is final and resolves the engine directly with new, you have no seam — proxy at the HTTP layer instead of fighting it.route:list (is the route there?) → middleware stack (is it my auth or theirs?) → RateLimiter::clear() (is it a test artifact?) → tinker the engine binding directly (is it the module or the backend?) → Log::debug the outgoing query. Most failures are in the first two steps.How can I help you explore Laravel packages today?