A fluent query builder for Meilisearch filter expressions in Laravel.
chr15k/laravel-meilisearch-advanced-query is a Laravel package for a fluent query builder for meilisearch filter expressions in laravel..
It currently has 8 GitHub stars and 58.586 downloads on Packagist (latest version 3.0.2).
Install it with composer require chr15k/laravel-meilisearch-advanced-query.
Discover more Laravel packages by chr15k
or browse all Laravel packages to compare alternatives.
Last updated
Meilisearch Advanced Query provides a fluent, expressive API for building Meilisearch filter expressions in Laravel — the same filters you'd otherwise write by hand when customising Scout engine searches.
It handles compound conditions, nested groups, range queries, geo filters, and Meilisearch-specific operators, keeping your code readable as queries grow in complexity.
[!WARNING] v3 contains breaking changes. If you are upgrading from v2, see the upgrade guide.
composer require chr15k/laravel-meilisearch-advanced-query
The service provider is registered automatically via Laravel's package discovery.
Use the Query facade (or resolve MeilisearchAdvancedQuery from the container directly) to build a filter string without touching Scout at all:
use Chr15k\MeilisearchAdvancedQuery\Facades\Query;
use Chr15k\MeilisearchAdvancedQuery\Enums\Operator;
$filter = Query::where('status', Operator::EQ, 'active')
->whereIn('role', ['admin', 'editor'])
->whereBetween('login_count', 10, 500)
->compile();
// "status = 'active' AND role IN ['admin', 'editor'] AND login_count 10 TO 500"
Chain forModel() on the query builder to hand off to Scout. This returns a Scout Builder instance that you can continue to chain as normal:
use Chr15k\MeilisearchAdvancedQuery\Facades\Query;
use Chr15k\MeilisearchAdvancedQuery\Enums\Operator;
use App\Models\Product;
$results = Query::where('status', Operator::EQ, 'active')
->whereIn('category', ['boots', 'shoes'])
->forModel(Product::class)
->search('leather')
->paginate(20);
Pass a sort expression (or array of expressions) to search():
Query::where('status', Operator::EQ, 'active')
->forModel(Product::class)
->search('leather', sort: ['price:asc', 'name:desc']);
For Meilisearch's sort syntax, see the sorting documentation.
Query FacadeThe Query facade proxies to a fresh MeilisearchAdvancedQuery instance on each call, so there is no shared state between requests.
use Chr15k\MeilisearchAdvancedQuery\Facades\Query;
You can also resolve the builder from the container directly if you prefer:
use Chr15k\MeilisearchAdvancedQuery\MeilisearchAdvancedQuery;
$query = app(MeilisearchAdvancedQuery::class);
where(field, operator, value, boolean)The primary method. Operator defaults to Operator::EQ. Boolean defaults to AND.
Query::where('name', Operator::EQ, 'Chris')->compile();
// "name = 'Chris'"
Query::where('count', Operator::GTE, 10)->compile();
// "count >= 10"
Query::where('verified', Operator::EQ, true)->compile();
// "verified = true"
orWhere(field, operator, value)Identical to where() but joins with OR.
Query::where('name', Operator::EQ, 'Chris')
->orWhere('name', Operator::EQ, 'Bob')
->compile();
// "name = 'Chris' OR name = 'Bob'"
whereNot(field, value) / orWhereNot(field, value)Negates a field equality check.
Query::whereNot('name', 'Chris')->compile();
// "NOT name = 'Chris'"
Query::where('verified', Operator::EQ, true)
->orWhereNot('name', 'Chris')
->compile();
// "verified = true OR NOT name = 'Chris'"
whereIn(field, values) / orWhereIn(field, values)Matches any value in the given array.
Query::whereIn('role', ['admin', 'editor'])->compile();
// "role IN ['admin', 'editor']"
Query::where('verified', Operator::EQ, true)
->orWhereIn('role', ['admin', 'editor'])
->compile();
// "verified = true OR role IN ['admin', 'editor']"
whereNotIn(field, values) / orWhereNotIn(field, values)Excludes any value in the given array.
Query::whereNotIn('status', ['banned', 'suspended'])->compile();
// "status NOT IN ['banned', 'suspended']"
whereBetween(field, from, to) / orWhereBetween(field, from, to)Range filter using Meilisearch's TO operator.
Query::whereBetween('price', 10, 100)->compile();
// "price 10 TO 100"
whereExists(field) / orWhereExists(field)Matches documents where the field exists.
Query::whereExists('verified_at')->compile();
// "verified_at EXISTS"
whereIsNull(field) / orWhereIsNull(field)Matches documents where the field is null.
Query::whereIsNull('deleted_at')->compile();
// "deleted_at IS NULL"
whereIsEmpty(field) / orWhereIsEmpty(field)Matches documents where the field is empty.
Query::whereIsEmpty('tags')->compile();
// "tags IS EMPTY"
whereRaw(query) / orWhereRaw(query)Passes a raw filter string through the compiler unchanged. Useful for filter expressions the builder does not yet support natively.
Query::whereRaw("name = 'Chris' OR name = 'Bob'")->compile();
// "name = 'Chris' OR name = 'Bob'"
Query::whereRaw("name = 'Chris'")
->orWhereRaw("name = 'Bob'")
->compile();
// "name = 'Chris' OR name = 'Bob'"
whereGeoRadius(lat, lng, distanceInMeters) / orWhereGeoRadiusQuery::where('active', Operator::EQ, true)
->whereGeoRadius(48.8566, 2.3522, 1000)
->compile();
// "active = true AND _geoRadius(48.8566, 2.3522, 1000)"
whereGeoBoundingBox(lat1, lng1, lat2, lng2) / orWhereGeoBoundingBoxQuery::where('active', Operator::EQ, true)
->whereGeoBoundingBox(48.8566, 2.3522, 48.9, 2.4)
->compile();
// "active = true AND _geoBoundingBox([48.8566, 2.3522], [48.9, 2.4])"
Pass a closure to where() or orWhere() to create a parenthesised group:
Query::where(fn ($q) => $q
->where('name', Operator::EQ, 'Chris')
->orWhere('name', Operator::EQ, 'Bob')
)
->where('verified', Operator::EQ, true)
->compile();
// "(name = 'Chris' OR name = 'Bob') AND verified = true"
Groups can be nested to any depth:
Query::where(fn ($q) => $q
->where('count', Operator::GTE, 10)
->where('count', Operator::LTE, 100)
->orWhere(fn ($sub) => $sub
->where('name', Operator::EQ, 'Chris')
->orWhereIsEmpty('name')
->orWhereIsNull('email')
)
)
->orWhere('name', Operator::EQ, 'Bob')
->compile();
// "(count >= 10 AND count <= 100 OR (name = 'Chris' OR name IS EMPTY OR email IS NULL)) OR name = 'Bob'"
See the Meilisearch filter expression reference for full documentation on each operator.
| Enum case | Meilisearch syntax |
|---|---|
| Operator::EQ | = |
| Operator::NEQ | != |
| Operator::GT | > |
| Operator::GTE | >= |
| Operator::LT | < |
| Operator::LTE | <= |
| Operator::IN | IN |
| Operator::NOT | NOT |
| Operator::BETWEEN | TO |
| Operator::EXISTS | EXISTS |
| Operator::NULL | IS NULL |
| Operator::EMPTY | IS EMPTY |
ScoutAdapter DirectlyforModel() is a convenience wrapper around ScoutAdapter. If you need more control — for example, to swap in a custom compiler — you can instantiate ScoutAdapter directly:
use Chr15k\MeilisearchAdvancedQuery\Adapters\ScoutAdapter;
use Chr15k\MeilisearchAdvancedQuery\Facades\Query;
use App\Models\Product;
$adapter = ScoutAdapter::for(
Product::class,
Query::where('status', Operator::EQ, 'active'),
);
$results = $adapter->search('leather', sort: ['price:asc']);
ScoutAdapter validates at instantiation that the given class exists, is an Eloquent model, and uses the Searchable trait — throwing InvalidArgumentException if any check fails.
Call compile() at any point in the chain to get the raw filter string without executing a search:
Query::where(fn ($q) => $q
->whereIn('role', ['admin', 'editor'])
->orWhereIsEmpty('verified_at')
)
->orWhere('email', Operator::EQ, '[email protected]')
->compile();
// "(role IN ['admin', 'editor'] OR verified_at IS EMPTY) OR email = '[email protected]'"
The package is structured around four concerns:
ComparisonNode, InNode, NotInNode, BetweenNode, GroupNode, RawNode)MeilisearchCompiler)MeilisearchAdvancedQuery)Builder (ScoutAdapter)The Compiler and Query contracts are independently extensible — you can implement your own compiler (for a different search engine's filter syntax) or your own query builder without touching the rest of the package.
composer test
Individual checks:
composer test:types # PHPStan static analysis
composer test:lint # Laravel Pint
composer test:unit # Pest (with coverage)
MIT — see LICENSE for details.