chr15k/laravel-meilisearch-advanced-query
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.
Requirements
- PHP 8.3+
- Laravel 12 or 13
- Laravel Scout 11+
- Meilisearch PHP SDK 1.16+
Installation
composer require chr15k/laravel-meilisearch-advanced-query
The service provider is registered automatically via Laravel's package discovery.
Usage
Building a filter string
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"
Running a Scout search
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);
Sorting
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.
The Query Facade
The 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);
Builder Methods
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'"
Geo filters
whereGeoRadius(lat, lng, distanceInMeters) / orWhereGeoRadius
Query::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) / orWhereGeoBoundingBox
Query::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])"
Nested / Grouped Queries
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'"
Supported Operators
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 |
Advanced: Using ScoutAdapter Directly
forModel() 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.
Debugging
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, 'chris@example.com')
->compile();
// "(role IN ['admin', 'editor'] OR verified_at IS EMPTY) OR email = 'chris@example.com'"
Architecture
The package is structured around four concerns:
- Nodes — immutable, typed value objects representing a single filter clause (
ComparisonNode,InNode,NotInNode,BetweenNode,GroupNode,RawNode) - Compiler — walks the node tree and produces a Meilisearch filter string (
MeilisearchCompiler) - Query builder — fluent API that constructs the node tree (
MeilisearchAdvancedQuery) - Scout adapter — bridges the compiled filter string to a Scout
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.
Running Tests
composer test
Individual checks:
composer test:types # PHPStan static analysis
composer test:lint # Laravel Pint
composer test:unit # Pest (with coverage)
Resources
- Building Advanced Meilisearch Queries in Laravel — a practical walkthrough of the package with real-world examples
License
MIT — see LICENSE for details.
Related Packages
Scout-style full-text search for Webman, Laravel, ThinkPHP, and Hyperf (Eloquent...
Laravel Scout extension that allows to use meilisearch advanced features as well...
Laravel, Lumen and Native php elasticseach query builder to build complex querie...
Driver for Laravel Scout search package based on https://github.com/teamtnt/tnts...
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| 3.0.2 | ^8.3 | ^12.0| | MIT | |
| 3.0.1 | ^8.3 | ^12.0| | MIT | |
| 3.0.0 | ^8.3 | ^12.0| | MIT | |
| 2.2.0 | ^8.0 | ^8.0| | MIT | |
| 2.1.0 | ^8.0 | ^8.0| | MIT | |
| 2.0.3 | ^8.0 | ^8.0| | MIT | |
| 2.0.2 | ^8.0 | ^8.0| | MIT | |
| 2.0.1 | ^8.0 | ^8.0| | MIT | |
| 2.0.0 | ^8.0 | ^8.0| | MIT | |
| 1.0.4 | ^8.0 | ^8.0| | MIT | |
| 1.0.3 | ^8.0 | ^8.0| | MIT | |
| 1.0.2 | ^8.0 | ^8.0| | MIT | |
| 1.0.1 | ^8.0 | ^8.0| | MIT | |
| 1.0.0 | ^8.0 | ^8.0| | MIT |