Looking to hire Laravel developers? Try LaraJobs

laravel-meilitools maintained by dwarfdk

Description
Additional tools for Laravel Scout integration with MeiliSearch
Last update
2026/10/09 11:29 (dev-main)
License
Links
Downloads
23 711

Comments
comments powered by Disqus

Laravel MeiliTools

PHP Packagist Downloads License GitHub Workflow Status

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.

Table of Contents

Compatibility

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.

Installation

Install this package via Composer:

composer require dwarfdk/laravel-meilitools

Configuration

Publish 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.

Usage

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

Index settings can be defined in Scout's configuration, on the model, or both.

Scout Configuration

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.

Model Settings

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.

Filtering

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 Model

Values 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

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.

Commands

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.

meili:index:create - Create a MeiliSearch index

Arguments:

  • index : Index name

meili:index:delete - Delete a MeiliSearch index

Arguments:

  • index : Index name

Options:

  • --force : Force the operation to run

meili:index:details - Get details for a MeiliSearch index

Arguments:

  • index : Index name

meili:index:reset - Reset settings for a MeiliSearch index

Arguments:

  • index : Index name

Options:

  • --pretend : Only shows what changes would have been done to the index

meili:index:synchronize - Synchronize settings for a MeiliSearch index configured in Scout

Arguments:

  • index : Index name, as configured in Scout's meilisearch.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

meili:index:view - Get base information about a MeiliSearch index

Arguments:

  • index : Index name

Options:

  • --stats : Whether to include index stats

meili:indexes:list - List all MeiliSearch indexes

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

meili:model:details - Get details for a MeiliSearch model index

Arguments:

  • model : Model class

meili:model:reindex - Reindex a model into a new MeiliSearch index without downtime

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's chunk.searchable
  • --timeout=300 : Seconds to wait for each MeiliSearch task
  • --force : Force the operation to run when in production

meili:model:reset - Reset settings for a MeiliSearch model index

Arguments:

  • model : Model class

Options:

  • --pretend : Only shows what changes would have been done to the index

meili:model:synchronize - Synchronize settings for a MeiliSearch model 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

meili:model:view - Get base information about a MeiliSearch model index

Arguments:

  • model : Model class

Options:

  • --stats : Whether to include index stats

meili:models:synchronize - Synchronize all models with MeiliSearch index settings

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

meili:stats - Get the stats of MeiliSearch or a MeiliSearch index

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

meili:tasks - List the most recent MeiliSearch tasks

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

meili:tasks:cancel - Cancel enqueued and processing MeiliSearch tasks

At least one filter is required.

Options:

  • --status=* : Only cancel tasks with the status, enqueued or processing
  • --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

About

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

Health

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" />

Examples

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

Upgrading

From 0.4.x to 1.0.0

  • PHP 8.4, Laravel 12, Scout 10 and MeiliSearch v1.36.0 are now the minimum supported versions.
  • laravel/scout is now a required dependency, and MeiliToolsScoutServiceProvider has been merged into MeiliToolsServiceProvider.
  • Engine version checks were removed, so Helpers::defaultSettings() and ValidatesIndexSettings methods no longer accept a version argument.
  • Actions use the Meilisearch\Client instead of Scout's EngineManager, 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 as true.
  • ArrayAssocRule now extends ValidationRule instead of the deprecated Rule contract.
  • Models without MeiliSettings can 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 meilisearch engine is replaced to apply it. Scout's where now only accepts the =, !=, <>, >, >=, < and <= operators, throwing on others, and null values are left out of whereIn and whereNotIn, as they never matched.

Development

Tests run against a MeiliSearch instance at http://localhost:7700 with the master key MeiliToolsMasterKey, as configured in phpunit.xml.

meilisearch --master-key MeiliToolsMasterKey

Run 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:check

Run tests with code coverage:

composer test:coverage

Fix code style and apply Rector refactorings:

composer lint
composer rector:fix

Laravel 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.json

Since 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.

Career

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.

License

The MIT License (MIT). Please see License File for more information.