LaravelPackages.net
Acme Inc.
Toggle sidebar
chr15k/laravel-meilisearch-advanced-query

A fluent query builder for Meilisearch filter expressions in Laravel.

58.586
8
3.0.2
About chr15k/laravel-meilisearch-advanced-query

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

Logo for Meilisearch Advanced Query package

GitHub Workflow Status (master) Total Downloads Latest Version License


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"

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, '[email protected]')
->compile();

// "(role IN ['admin', 'editor'] OR verified_at IS EMPTY) OR email = '[email protected]'"

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


License

MIT — see LICENSE for details.

Comments