divineomega/laravel-omega-search
Omega Search brings flexible, driver-based full-text search to Laravel. Make Eloquent models searchable, define indexed fields, and run ranked queries through a clean, unified API. Swap search backends without rewriting code—simple to configure, easy to extend, and built for real apps.
Install and wire it up
composer require <vendor>/omega-search
php artisan vendor:publish --tag=omega-config
Set your connection/index config in config/omega.php, then add the package's searchable trait and a search scope to the models you want searchable. Once the trait is in place, the canonical first query is a relevance-ordered, paginated Eloquent query:
$results = Post::search($request->q)
->paginate(20);
Where to look first
WHERE IN → ORDER BY relevance pipeline lives. That's the file to read when ordering or result counts look wrong.First use case
The lowest-risk entry point is a read-only search page over an already-populated table. Because the scope returns a normal Eloquent builder, you can layer existing constraints (->where('published', true)), eager loads, and pagination on top without touching the package's internals.
Model scope + standard Eloquent chaining
Treat the search scope as a filter, not a terminal operation. Compose it with your existing query pipeline so authorization, soft deletes, and tenant scoping stay where they already live:
$posts = Post::search($term)
->where('tenant_id', $tenant->id)
->with('author')
->orderByDesc('published_at')
->paginate(20);
Note the ordering interaction — see Gotchas.
Rely on the empty-result guard
v4.1.0 makes "no matching IDs" return an empty Eloquent query rather than an unconstrained one. This is the behavior you want, and it makes the search scope safe to use in a pipeline without a conditional:
$posts = Post::search($term)->where('published', true)->paginate(20);
// zero matches => zero rows, not "all published posts"
Do not wrap the scope in if ($ids) guards anymore; that defensive pattern exists to work around the old behavior and now just adds noise.
Cross-engine portability
Relevance ordering is now expressed as a parameter-bound CASE instead of MySQL FIELD(). This means the same query runs on MySQL, MariaDB, PostgreSQL, and SQLite. Practically:
Index synchronization
The search path depends on a maintained ID/term index. Choose one pattern deliberately:
Backfill explicitly after deploy: any model rows created before the trait/index existed won't be findable until they're indexed.
Testing
Assert against ->pluck('id')->all() with deterministic fixtures ordered by a known relevance, and always cover the zero-match case (it's the case that regressed before 4.1.0). Run the suite on at least two drivers if you support more than one — that's exactly what the package's new compatibility tests do.
The 4.1.0 empty-result change is a behavior change in disguise
Old behavior: no matching IDs → the constraint was dropped → you got every model back. Anywhere your code silently depended on that (an "everything" browse page, a fallback search, a test asserting count() > 0 for a nonsense term), it will now return nothing. Grep for search callers whose empty state was never designed, and add an explicit fallback:
$posts = Post::search($term)->paginate(20);
if ($posts->isEmpty()) {
$posts = Post::where('published', true)->latest()->paginate(20); // intentional fallback
}
This is also worth flagging as a quiet data-exposure fix: previously a query on a private model could leak the whole table if the index returned nothing.
CASE ordering is not index-friendly
Portability cost: the relevance order is computed per row. Keep the candidate ID set small by pushing WHERE constraints into the query before ordering, and paginate early. If result sets get large, profile on your target engine — this is the main performance tradeoff of the 4.1.0 change.
Bind-parameter limits
The ID list is bound, not interpolated (good), but large ID sets still hit driver limits — SQLite historically caps around 999 placeholders, MySQL has a similar bind ceiling. Cap the number of index IDs the package fetches, or chunk the search. Symptoms: sudden "too many SQL variables" errors that only appear on broad queries.
Ordering conflicts
If you append your own orderBy after the search scope, you may override relevance ordering entirely. Decide explicitly: relevance-first (no orderBy after), or recency-first (accept losing relevance). Don't assume relevance survives.
Relevance scores aren't portable
Order is preserved across engines, but scores and tie-breaking can differ. Don't assert exact ordering on ties in tests without a deterministic secondary sort.
Version floor constrains contributor code
Laravel 5.1–13 / PHP 7.1 support means no typed properties, no arrow functions, no named arguments, and no Builder methods newer than 5.1 in any code path you share back. If you're patching the package, mirror its style or your PR will fail the lowest-compatibility suite.
Debugging
The scope returns a real builder — dump ->toSql() and ->getBindings() to see the generated CASE and the ID bindings. If results are empty when you expect hits, check the index first (is the row in the index at all?) before suspecting the query.
Extension points
The order/scoring expression and the ID-resolution step are the two seams worth customizing. Prefer a subclass or config override over editing vendor code, and keep custom ordering in one place so the next release doesn't silently diverge from your fork.
How can I help you explore Laravel packages today?