The purpose of this package is to ease the configuration of indexes for MeiliSearch, so it's possible to use advanced filtering and sorting through Laravel Scout, without having to meddle with their API manually.
| Package | PHP | Laravel | Scout | MeiliSearch engine |
|---|---|---|---|---|
| 1.x | 8.4 - 8.5 | 12.x - 13.x | 10.x - 11.x | >= v1.36.0 |
| 0.4.x | 8.2 - 8.4 | 10.x - 12.x | 9.x - 10.x | v1.x.x |
| 0.3.x | v0.28.x - v0.30.x | |||
| 0.2.x | v0.26.x - v0.27.x | |||
| 0.1.x | v0.26.x - v0.27.x |
MeiliSearch v1.36.0 changed the default ranking rules, which is the latest engine change requiring changes in this package.
Install this package via Composer:
composer require dwarfdk/laravel-meilitoolsPublish config using Artisan command:
php artisan vendor:publish --provider="Dwarf\MeiliTools\MeiliToolsServiceProvider"Change configuration through config/meilitools.php, which contains the paths scanned for models when synchronizing all models,
and the API key signing tenant tokens.
Scout must use the meilisearch driver, configured with SCOUT_DRIVER, MEILISEARCH_HOST and MEILISEARCH_KEY
as described in Scout's documentation.
This package provides commands, actions and a search builder for configuring and searching MeiliSearch indexes.
Each command uses an action bound to a contract in Dwarf\MeiliTools\Contracts\Actions, which can be called directly,
e.g. resolve(SynchronizesModel::class)(Article::class), or replaced by binding your own implementation.
All settings are validated before being sent to MeiliSearch, and only actual changes are applied.
The following settings are supported:
dictionary, displayedAttributes, distinctAttribute, facetSearch, faceting, filterableAttributes,
localizedAttributes, nonSeparatorTokens, pagination, prefixSearch, proximityPrecision, rankingRules,
searchCutoffMs, searchableAttributes, separatorTokens, sortableAttributes, stopWords, synonyms and typoTolerance.
filterableAttributes accepts both attribute names and granular rules with attributePatterns and features.
Experimental settings and embedders are not managed, and are ignored if present.
A full description of the index settings can be found here.
Index settings can be defined in Scout's configuration, on the model, or both.
Settings defined in Scout's meilisearch.index-settings configuration are used by this package as well,
keyed by either model class or index name, the same way as Scout's scout:sync-index-settings command.
// config/scout.php
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
Article::class => [
'filterableAttributes' => ['status'],
'sortableAttributes' => ['published_at'],
],
'books' => [
'searchableAttributes' => ['title', 'author'],
],
],
],Index names are prefixed with Scout's prefix configuration, unless already prefixed.
Setup index settings for a model by implementing the method provided by the contract.
use Dwarf\MeiliTools\Contracts\Indexes\MeiliSettings;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
class Article extends Model implements MeiliSettings
{
use Searchable;
use SoftDeletes;
/**
* {@inheritdoc}
*/
public function meiliSettings(): array
{
// With Scout's `soft_delete` enabled, '__soft_deleted' is automatically added to filterable attributes.
return ['filterableAttributes' => ['status']];
}
}Settings for a model are merged from Scout's configuration keyed by the model's index name, Scout's configuration
keyed by the model class, and the model's meiliSettings() method, with later sources taking precedence.
Searching models using MeiliSearch returns a builder with an Eloquent style API for MeiliSearch filters,
in addition to Scout's own where, whereIn and whereNotIn methods.
Filtered attributes must be filterable, and sorted attributes must be sortable, in the index settings.
use Dwarf\MeiliTools\Contracts\Filtering\FilterBuilder;
$articles = Article::search('laravel')
->where('status', 'published')
->where('views', '>=', 100)
->where(fn (FilterBuilder $filter) => $filter
->whereIn('category', ['news', 'tutorials'])
->orWhereNull('category'))
->whereNot('author', 'bot')
->get();Scout declares search() as returning its own builder, so for IDE and static analysis support, add the following to the model:
/**
* @method static \Dwarf\MeiliTools\Filtering\SearchBuilder<static> search(string $query = '', ?\Closure $callback = null)
*/
class Article extends ModelValues are formatted for MeiliSearch: strings are quoted and escaped, backed enums use their value, and dates are converted to Unix timestamps, so dates must be indexed as timestamps to be filterable.
The following filter methods are available, each with orWhere variants, and most with whereNot variants:
| Method | MeiliSearch filter |
|---|---|
where('rank', '>', 3), where('rank', 3) |
rank > 3, rank = 3 (operators =, !=, >, >=, <, <=) |
where(fn (FilterBuilder $filter) => ...) |
Nested group in parentheses |
whereNot('genre', 'drama') |
NOT (genre = "drama"), also accepting a closure |
whereIn('genre', [...]), whereNotIn(...) |
genre IN [...], genre NOT IN [...] |
whereBetween('rank', [1, 5]) |
rank 1 TO 5 |
whereNull('rank'), where('rank', null) |
rank IS NULL |
whereEmpty('tags') |
tags IS EMPTY |
whereExists('rank') |
rank EXISTS |
whereStartsWith('title', 'Bat') |
title STARTS WITH "Bat" |
whereContains('title', 'man') |
title CONTAINS "man", requiring the experimental containsFilter feature |
whereGeoRadius($lat, $lng, $distance, DistanceUnit::Meters) |
_geoRadius(lat, lng, meters), with the distance converted to meters from the Enums\Filtering\DistanceUnit cases Meters, Kilometers, Miles or Feet |
whereGeoBoundingBox([$lat, $lng], [$lat, $lng]) |
_geoBoundingBox([lat, lng], [lat, lng]), with the top right and bottom left corners |
whereGeoPolygon([[$lat, $lng], ...]) |
_geoPolygon([lat, lng], ...), requiring _geojson to be filterable |
whereRaw('rank = 3 OR genre = drama') |
Raw filter expression in parentheses, skipped when empty |
The builder also adds the following search options:
Article::search('laravel')
->orderByGeo($lat, $lng) // Sort by distance, using `_geoPoint(lat, lng):asc`
->matchingStrategy('frequency') // `last`, `all` or `frequency`
->rankingScoreThreshold(0.5) // Exclude results ranked below the threshold
->attributesToSearchOn(['title']) // Restrict which searchable attributes are searched
->distinct('slug') // Return one result per value of a filterable attribute
->locales(['eng']) // Search using specific locales
->get();Facets count the matching documents per value of filterable attributes, and are available after searching:
$search = Article::search('laravel')->facets(['category', 'views']);
$articles = $search->paginate();
$search->facetDistribution(); // ['category' => ['news' => 12, 'tutorials' => 4], 'views' => [...]]
$search->facetStats(); // ['views' => ['min' => 10, 'max' => 5300]]These options take precedence over the same keys given to Scout's options().
The filter is combined with any filter Scout sets, e.g. for soft deletes, and with a filter given to options(),
and the search parameters are also given to a search callback.
Filters and search parameters are applied by the package's MeiliSearch engine, which replaces Scout's meilisearch engine.
If you register your own MeiliSearch engine, use the Dwarf\MeiliTools\Filtering\Concerns\AppliesSearchBuilder trait in it,
as models using an engine without it get Scout's own builder.
Filtering behaviour can be changed by binding your own implementations of the contracts in the service container:
| Contract | Default | Purpose |
|---|---|---|
Contracts\Filtering\SearchBuilder |
Filtering\SearchBuilder |
Builder returned by Model::search(), which must extend Scout's builder and not be a singleton |
Contracts\Filtering\FilterBuilder |
Filtering\FilterBuilder |
Builder given to nested filter closures |
Contracts\Filtering\FormatsFilterValues |
Filtering\FilterValueFormatter |
Formatting of fields and values, e.g. dates |
For example, to filter on dates indexed as Y-m-d strings instead of timestamps:
use Dwarf\MeiliTools\Contracts\Filtering\FormatsFilterValues;
use Dwarf\MeiliTools\Filtering\FilterValueFormatter;
$this->app->bind(FormatsFilterValues::class, fn () => new class extends FilterValueFormatter {
public function value(mixed $value): string
{
return parent::value($value instanceof DateTimeInterface ? $value->format('Y-m-d') : $value);
}
});Tenant tokens let users search MeiliSearch directly, e.g. from the frontend, while only finding the documents they're allowed to.
The tokens are signed with an API key allowing searches, which is configured with MEILITOOLS_TENANT_TOKEN_KEY.
The master key can't sign tenant tokens. Setting MEILITOOLS_TENANT_TOKEN_KEY_UID to the uid of the key saves looking it up for every token.
Search rules are keyed by index name, index pattern or searchable model class.
Each rule is a filter expression, a filter builder, a closure receiving a filter builder, or null to allow every document:
use Dwarf\MeiliTools\Contracts\Actions\GeneratesTenantToken;
use Dwarf\MeiliTools\Contracts\Filtering\FilterBuilder;
$token = resolve(GeneratesTenantToken::class)(
[
Article::class => fn (FilterBuilder $filter) => $filter
->where('team_id', $user->team_id)
->orWhere('public', true),
'tags' => null,
],
expiresAt: now()->addDay(),
);A list of indexes allows every document in them, e.g. ['*'] for all indexes.
The API key and its uid can also be given as apiKey and apiKeyUid.
The following commands are available. Index and model arguments are asked for when omitted, except by meili:stats,
and --pretend and --stats can be shortened to -P and -S.
Arguments:
index: Index name
Arguments:
index: Index name
Options:
--force: Force the operation to run
Arguments:
index: Index name
Arguments:
index: Index name
Options:
--pretend: Only shows what changes would have been done to the index
Arguments:
index: Index name, as configured in Scout'smeilisearch.index-settings
Options:
--pretend: Only shows what changes would have been done to the index--check: Only checks whether the settings are in sync, failing when they are not
Arguments:
index: Index name
Options:
--stats: Whether to include index stats
Options:
--stats: Whether to include index stats
meili:indexes:synchronize - Synchronize all MeiliSearch indexes configured in Scout which are not bound to a model
Indexes belonging to models synchronized by meili:models:synchronize are skipped. Exits with a failure code if any index fails to synchronize.
Options:
--pretend: Only shows what changes would have been done to the indexes--check: Only checks whether the settings are in sync, failing when they are not--force: Force the operation to run when in production
Arguments:
model: Model class
Imports all searchable models into a temporary index with the model settings, then swaps it with the model index. Searches keep using the old index until the swap, and the old index is kept when the import fails. Models changed while importing aren't updated in the new index, unless they're imported after the change.
Arguments:
model: Model class
Options:
--chunk=: Number of models to import at a time, defaulting to Scout'schunk.searchable--timeout=300: Seconds to wait for each MeiliSearch task--force: Force the operation to run when in production
Arguments:
model: Model class
Options:
--pretend: Only shows what changes would have been done to the index
Arguments:
model: Model class
Options:
--pretend: Only shows what changes would have been done to the index--check: Only checks whether the settings are in sync, failing when they are not
Arguments:
model: Model class
Options:
--stats: Whether to include index stats
Synchronizes models in the configured paths implementing MeiliSettings, as well as models configured in Scout's meilisearch.index-settings.
Exits with a failure code if any model fails to synchronize.
Options:
--pretend: Only shows what changes would have been done to the indexes--check: Only checks whether the settings are in sync, failing when they are not--force: Force the operation to run when in production
Shows the database size and the documents of each index, or the stats of a single index, including how many documents contain each field.
Arguments:
index: Index name, showing the stats of all indexes when omitted
Shows the status, duration and any error of each task, e.g. to find out why documents weren't indexed.
Options:
--status=*: Only list tasks with the status, e.g.failed--type=*: Only list tasks of the type, e.g.documentAdditionOrUpdate--index=*: Only list tasks of the index--limit=20: Maximum number of tasks to list
At least one filter is required.
Options:
--status=*: Only cancel tasks with the status,enqueuedorprocessing--type=*: Only cancel tasks of the type, e.g.documentAdditionOrUpdate--index=*: Only cancel tasks of the index--uid=*: Only cancel the task with the uid--force: Force the operation to run
The about command includes a Meilisearch section with the Scout driver, host, engine version, number of indexes and database size,
showing the version as Unreachable when Meilisearch can't be reached:
$ php artisan about --only=meilisearch
The health of Meilisearch can be monitored with Spatie Laravel Health or Laravel Pulse, which must be installed separately. Both report whether Meilisearch is reachable, whether the indexes of models and Scout's configuration exist with their settings in sync, and tasks which failed recently. Indexes are only checked, never created or changed.
Register the check with Spatie Laravel Health, which fails when Meilisearch is unreachable, its indexes or tasks can't be listed, an index is missing or settings fail validation, and warns when settings are out of sync or tasks failed:
use Dwarf\MeiliTools\Health\MeiliToolsCheck;
use Spatie\Health\Facades\Health;
Health::checks([
MeiliToolsCheck::new()
->failedTasksWithin(60) // Minutes, 60 by default
->checkSettings(), // Whether to compare settings, enabled by default
]);Add the card to the Laravel Pulse dashboard, after publishing it with php artisan vendor:publish --tag=pulse-dashboard:
<livewire:meilitools.pulse cols="4" />The card is cached for 60 seconds, and shows tasks which failed within the last 60 minutes, which can both be changed:
<livewire:meilitools.pulse cols="4" :ttl="120" :failed-tasks-within-minutes="30" />The --check option of the synchronize commands fails when settings are out of sync without changing them,
e.g. to verify settings in CI or before deploying. Like --pretend, it compares missing indexes with the default settings
without creating them:
$ php artisan meili:models:synchronize --check
$ php artisan meili:indexes:synchronize --check
Model commands can take both full class name and base name, with the latter being completed using the configured paths.
$ php artisan meili:model:details App\\Models\\Article
$ php artisan meili:model:details Article
$ php artisan meili:model:reindex App\\Models\\Article
$ php artisan meili:model:reindex Article
$ php artisan meili:model:reset App\\Models\\Article
$ php artisan meili:model:reset Article
$ php artisan meili:model:synchronize App\\Models\\Article
$ php artisan meili:model:synchronize Article
$ php artisan meili:model:view App\\Models\\Article
$ php artisan meili:model:view Article
Recent failed tasks, e.g. documents which couldn't be indexed, can be listed with:
$ php artisan meili:tasks --status=failed
- PHP 8.4, Laravel 12, Scout 10 and MeiliSearch v1.36.0 are now the minimum supported versions.
laravel/scoutis now a required dependency, andMeiliToolsScoutServiceProviderhas been merged intoMeiliToolsServiceProvider.- Engine version checks were removed, so
Helpers::defaultSettings()andValidatesIndexSettingsmethods no longer accept a version argument. - Actions use the
Meilisearch\Clientinstead of Scout'sEngineManager, and action contracts now include all parameters. - List settings are validated as lists, and booleans and integers are validated strictly, e.g.
'1'is no longer accepted astrue. ArrayAssocRulenow extendsValidationRuleinstead of the deprecatedRulecontract.- Models without
MeiliSettingscan be synchronized using settings from Scout's configuration, instead of throwing an exception. - Pretending to synchronize or reset no longer creates missing indexes, comparing them with the default settings instead.
- Searching models using MeiliSearch returns the package's search builder, and Scout's
meilisearchengine is replaced to apply it. Scout'swherenow only accepts the=,!=,<>,>,>=,<and<=operators, throwing on others, andnullvalues are left out ofwhereInandwhereNotIn, as they never matched.
Tests run against a MeiliSearch instance at http://localhost:7700 with the master key MeiliToolsMasterKey, as configured in phpunit.xml.
meilisearch --master-key MeiliToolsMasterKeyRun all checks (Composer Normalize, Rector, Pint, PHPStan and type coverage) followed by the tests, or with a security audit as done in CI:
composer test
composer ci:checkRun tests with code coverage:
composer test:coverageFix code style and apply Rector refactorings:
composer lint
composer rector:fixLaravel Boost is included as a development dependency, and runs through Testbench.
Its files (AGENTS.md, boost.json, .mcp.json etc.) are git-ignored, so set it up locally:
vendor/bin/testbench boost:install
cp vendor/orchestra/testbench-core/laravel/boost.json boost.jsonSince packages don't have an artisan file, change the generated MCP server command to php vendor/bin/testbench boost:mcp.
Guidelines, including the project guidelines in .ai/guidelines, can afterwards be updated with composer boost:update.
Dwarf A/S is a digital agency based in Copenhagen (Denmark) and established January 1st 2000.
We're always looking for new talent, so have a look at our website for job openings.
The MIT License (MIT). Please see License File for more information.