Larameili

Larameili gives a Meilisearch index an Eloquent-style model, for documents that live in the search engine as their primary store: find, search, save and delete through a model class, with the index settings declared in code.
Installation
One Composer package, and the service provider auto-registers:
composer require edulazaro/larameili
It reads the same MEILISEARCH_HOST and MEILISEARCH_KEY your app already uses, so an app that already talks to Meilisearch needs nothing else.
The model is the index
A model maps to one index. Declare the index name, the fields to filter and sort on, and the settings the index should have.
use EduLazaro\Larameili\Meili; class Article extends Meili { protected static string $index = 'articles'; protected static array $searchable = ['title', 'body']; protected static array $filterable = ['status', 'author_id']; protected static array $sortable = ['published_at']; }
find(), save(), delete() and search() all talk to the index. There is no table behind it and no second store to keep in step.
Pushing the settings
The $searchable, $filterable, $sortable, $ranking, $synonyms and $embedders you declare live in code until you sync them. List your models in config/larameili.php and run the command, which creates each index and applies its settings:
php artisan meili:sync
meili:sync creates the index if it does not exist and is safe to run repeatedly.
Reading and writing
It reads like Eloquent.
$article = Article::find('abc'); // by primary key, or null $article->title = 'New title'; $article->save(); // insert or update $article->delete(); Article::create(['id' => 'xyz', 'title' => 'Hello']);
find() returns null only when the document genuinely does not exist. A missing index, a bad key or an unreachable host raise the Meilisearch client's own exception instead of being hidden as a null.
Querying
query() returns a fluent builder that compiles into Meilisearch parameters and hydrates the hits back into models.
$hits = Article::query() ->where('status', 'published') ->whereIn('author_id', [1, 2, 3]) ->orderBy('published_at', 'desc') ->limit(20) ->search('meilisearch');
The builder covers the rest of the search surface too:
| Method | What it does |
|---|---|
where / whereIn / whereNot / whereRaw |
Filters, compiled to Meilisearch syntax |
semantic($ratio) |
Hybrid keyword + vector search when the index has an embedder |
paginate($perPage) |
A Laravel LengthAwarePaginator with an exact total |
near / withinBox / orderByDistance |
Geo search on a _geo field |
count / exists |
Aggregate helpers |
Hybrid search
If the index declares an embedder, semantic() turns the query into a hybrid one: Meilisearch runs the keyword and the vector search together and fuses the rankings. The ratio is 0 for keyword only, 1 for vector only, and 0.5 for an even blend.
Article::query() ->where('status', 'published') ->semantic(0.5) ->search('how do I cancel a subscription');
Relations to Eloquent models
Meilisearch has no joins, so a relation is a resolver: a document holds a foreign key, and the package looks the Eloquent model up by it. Declare one with belongsToEloquent.
use EduLazaro\Larameili\Relations\BelongsToEloquent; class LawChunk extends Meili { public function law(): BelongsToEloquent { return $this->belongsToEloquent(Law::class, foreignKey: 'law_id', ownerKey: 'external_id'); } }
Read $chunk->law and it resolves lazily; eager-load it on a search with with('law') to batch every hit's lookup into a single Eloquent query instead of one per hit.
Casts
Meilisearch returns raw JSON, so a $casts map turns attributes back into the types you want as you read them: dates into Carbon, JSON into arrays, strings into backed enums.
protected static array $casts = [ 'published_at' => 'datetime', 'tags' => 'array', 'status' => Status::class, ];
Supported casts: int, float, bool, string, array, collection, date, datetime, and any backed enum class. Casting is applied on read.
built and maintained by Edu Lazaro · MIT license