The chunks already live in the search engine, and the copy in the database does nothing

This is me

This is me

Building Inis, the legal assistant inside crowd.legal, I had a few million chunks of legislation to search: articles of Spanish law split into passages, embedded, and dropped into Meilisearch so the assistant could retrieve the relevant ones and cite them. They are only ever used one way: give me the passages relevant to this question, inside this filter.

The reflex was to keep them in Postgres too, the way Scout does it: a table is the source of truth, and the index is a mirror kept in step by an observer. I wrote the migration. Then I stopped, because I could not name a single thing the table was for. Nothing joined a chunk. Nothing edited one by hand. The only reader was the search index, and the index already had them.

What the table would have bought me was work: a sync command, a queue worker, drift to debug when a job failed, and a full reindex the day the embedding model changed. None of that makes the search any better. So I deleted the migration and let the index be the source of truth. What I missed afterwards was not the database. It was the comfort of Eloquent: find, search, save, relate. Larameili is that comfort put back on top of Meilisearch.

The index is the model, not a mirror of one

Scout's model is the record, and the index is a projection of it. That is the right shape when the record is real: a User, an Order, something with a life in the database. It is the wrong shape when the document has no such life. A chunk is not a projection of anything. It is the primary artifact, and it lives in Meilisearch.

So in Larameili the model does not point at a table. It points at an index, and its attributes are the document.

class Chunk extends Meili
{
    protected static string $index = 'chunks';

    protected static array $filterable = ['law_id', 'type'];
}

find(), save(), delete() and search() all talk to the index, because there is no second store to keep in step. The decision that everything else follows from is this one: the index is not a cache of the truth, it is the truth.

find() returns null, or it raises

This is the small decision I care about most, because the opposite of it cost me an afternoon once.

find() returns null when the document does not exist. That is the obvious half. The half that matters is what it does when something is actually wrong: a misnamed index, a bad key, a host that is down. The tempting move, and the one a lot of wrapper code makes, is to catch broadly and return null there too, so the caller gets one simple contract.

That contract is a trap. A caller that reads null as "not found" will read a downed search host as "not found", and then the failure surfaces three layers away as an empty result instead of an error, which is where you start debugging the wrong thing. I have done exactly that, in a document classifier that quietly returned nothing because the code underneath had swallowed a real error as an absence.

So Larameili narrows it. find() returns null only on a genuine document_not_found. Every other failure is the Meilisearch client's own exception, raised, with its own message. "Not found" and "broken" are different answers, and the caller gets to tell them apart.

$law = Law::find('BOE-A-2018-16673');   // null only if it truly is not there

Relations are resolvers, not joins

A chunk belongs to a law, and the law is a real entity that does deserve a table. The citation, the title, the date and the source come from Postgres, while the passage comes from Meilisearch. Those two facts have to meet somewhere.

Meilisearch has no joins, and I did not want to pretend otherwise. So the relation is a resolver: the document holds a foreign key, and the package looks the Eloquent model up by it. I named the method belongsToEloquent, not belongsTo, on purpose, so the call site never forgets which side of the fence it is standing on.

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() and every hit's lookup collapses into a single whereIn, so a page of results is one extra query and not one per row. The passage is from the index, the entity is from the database, and there is no N+1 in the seam.

The trade you take on purpose

Letting the index be the source of truth costs you transactions, joins and constraints. For chunks that is not a loss, because you were never going to open a transaction over a passage of text. For an invoice it would be a catastrophe.

That is the whole discipline of the thing, and it is worth saying out loud, because the API is comfortable enough to lead you astray. Once find, save and search feel like Eloquent, there is a pull to move more into Meilisearch simply because it is pleasant to write. Resist it. The relational schema keeps the entities that earn a table, the ones with money and foreign keys and audit trails. Meilisearch keeps the documents that exist only to be searched. Larameili is for the second kind, and the moment you are reaching for it to hold the first kind, you have picked the wrong tool.

What it bought

Inis retrieves from a few million legislative passages that live in one place, with no sync job to babysit and no second copy to drift. The chunks read like models, the laws they cite come from the database in one query, and the retrieval code is a handful of lines instead of a hand-rolled client.

The full API, hybrid search, pagination, geo and the settings-as-code command are on the Larameili page. Source on GitHub, package on Packagist.