Things I Learned Using Meilisearch with Laravel Scout

Practical lessons from using Meilisearch with Laravel Scout, from filtering Eloquent queries to keeping related model data in sync.

I started using Meilisearch when database queries were no longer the best fit for some of the searching and filtering needs in an application I was working on.

Getting Meilisearch running with Laravel Scout was surprisingly straightforward. The interesting part came later.

Once I started using it for real application queries, I realized there were several things that weren’t immediately obvious: Eloquent queries don’t automatically use Meilisearch, filtering requires additional configuration, and indexing data from relationships introduces another synchronization problem.

This article covers those lessons.

Why Meilisearch?

Before introducing a dedicated search engine, most searching and filtering in the application was handled directly by the database.

For example:

SalesOrder::where('status', 'READY')
    ->where('channel', 'Shopee')
    ->get();

There is nothing inherently wrong with this.

Relational databases are very good at filtering structured data, especially when the right indexes are in place. But as the amount of data and search requirements grow, not every search workload needs to stay in the primary database.

At that point, I started looking at dedicated search engines.

Elasticsearch was an obvious option. It is powerful, mature, and supports much more complex search and analytics use cases.

But that also comes with additional operational complexity.

For my use case, the requirements were relatively straightforward:

  • fast searching,
  • fast filtering,
  • easy integration with Laravel,
  • and simple infrastructure.

Meilisearch covered those requirements without introducing more complexity than necessary.

Combined with Laravel Scout, getting a model indexed was also simple.

use Laravel\Scout\Searchable;

class SalesOrder extends Model
{
    use Searchable;
}

Then existing records can be imported into the search index:

php artisan scout:import "App\Models\SalesOrder"

At first, it looked almost too easy.

And that’s where my first misunderstanding started.

Eloquent Queries Don’t Automatically Use Meilisearch

Adding the Searchable trait to a model does not change how normal Eloquent queries work.

This query:

SalesOrder::where('status', 'READY')->get();

still goes to the database.

It does not go through Laravel Scout, and Meilisearch is not involved at all.

This sounds obvious once you understand how Scout works, but it is an easy assumption to make when first introducing a search engine into an existing Laravel application.

Laravel Scout provides a separate query builder.

To query the search engine, the query needs to start from search():

SalesOrder::search('')
    ->where('status', 'READY')
    ->get();

The mental model I ended up using is simple:

SalesOrder::query()

     Database

SalesOrder::search()

 Laravel Scout

   Meilisearch

The Searchable trait makes the model available to Scout. It does not replace Eloquent’s database query builder.

That distinction becomes important when migrating an existing feature from database filtering to Meilisearch.

You cannot simply configure Scout and expect existing where() queries throughout the application to suddenly use the search engine.

They are two different query paths.

Filtering Requires Additional Configuration

After switching a query to Scout, I ran into another difference.

Suppose I want to filter sales orders by status:

SalesOrder::search('')
    ->where('status', 'READY')
    ->get();

Having status inside the indexed document is not enough.

Meilisearch needs to know that the field is allowed to be used for filtering.

With Laravel Scout, this can be configured in config/scout.php:

'meilisearch' => [
    'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
    'key' => env('MEILISEARCH_KEY'),

    'index-settings' => [
        SalesOrder::class => [
            'filterableAttributes' => [
                'status',
                'channel',
            ],
        ],
    ],
],

After changing index settings, they need to be synchronized with Meilisearch:

php artisan scout:sync-index-settings

This taught me an important distinction between searchable data and filterable data.

A field existing in the Meilisearch document does not automatically mean it can be used in a filter.

For example, an indexed sales order might look like this:

{
  "id": 12345,
  "order_number": "SO-2608-00123",
  "customer_name": "John Doe",
  "status": "READY",
  "channel": "Shopee"
}

customer_name might be useful for text searching, while status and channel are more useful as filters.

Those are different responsibilities.

Thinking about the index this way made the configuration much clearer:

What fields do users need to search, and what fields does the application need to filter?

Instead of simply putting everything into the index and expecting Meilisearch to behave like SQL.

Meilisearch Is Not a Relational Database

The next problem became more interesting when relationships were involved.

In a relational database, the information required to filter a sales order does not necessarily live in the sales_orders table.

It might look something like this:

SalesOrder

    └── SalesOrderDetail

              └── Product

With Eloquent, accessing this data feels natural.

You can use relationships, whereHas(), joins, or other database queries to reach the information you need.

For example:

SalesOrder::whereHas('details.product', function ($query) use ($productId) {
    $query->where('id', $productId);
})->get();

But Meilisearch doesn’t know anything about Laravel relationships.

It doesn’t perform a SQL join when a search request comes in.

It only knows about the documents that were sent to its index.

This means that if I want to filter SalesOrder using information from a related model, that information needs to become part of the indexed SalesOrder document.

Flattening Relationship Data Into the Search Index

Laravel Scout allows us to control the representation sent to the search engine using toSearchableArray().

For example:

public function toSearchableArray(): array
{
    return [
        'id' => $this->id,
        'order_number' => $this->order_number,
        'status' => $this->status,
        'channel' => $this->channel,

        'product_ids' => $this->details
            ->pluck('product_id')
            ->filter()
            ->unique()
            ->values()
            ->all(),
    ];
}

Instead of trying to reproduce the database relationship inside Meilisearch, the information needed for searching and filtering is flattened into the document.

The resulting document might look like this:

{
  "id": 12345,
  "order_number": "SO-2608-00123",
  "status": "READY",
  "channel": "Shopee",
  "product_ids": [1001, 1002, 1003]
}

Then product_ids can be registered as a filterable attribute:

'filterableAttributes' => [
    'status',
    'channel',
    'product_ids',
],

And queried through Scout:

SalesOrder::search('')
    ->where('product_ids', 1001)
    ->get();

This changed how I thought about the search index.

The goal is not to copy the database structure into Meilisearch.

The goal is to build a document containing the information required to answer search queries efficiently.

In other words, the search index is intentionally denormalized.

Then Comes the Synchronization Problem

Flattening relationship data solves the filtering problem, but introduces another one.

Consider this indexed document:

{
  "id": 12345,
  "status": "READY",
  "product_ids": [1001, 1002]
}

Now imagine a SalesOrderDetail changes and product 1003 is added to the order.

The database becomes:

SalesOrder 12345
    ├── Product 1001
    ├── Product 1002
    └── Product 1003

But the SalesOrder itself may not have been updated.

From Laravel Scout’s perspective, this matters.

Scout automatically keeps searchable models synchronized when those models are created, updated, or deleted.

But in this case, the model that changed was not necessarily SalesOrder.

It was a related model.

So the database may now contain:

product_ids = [1001, 1002, 1003]

while Meilisearch still contains:

product_ids = [1001, 1002]

The database is correct.

The search index is stale.

This is especially dangerous because nothing necessarily throws an error. The application still works, but filtering can silently return incomplete results.

Once relationship data becomes part of a search document, we also need to think about which model changes can invalidate that document.

One approach is to explicitly reindex the parent model when a relevant relationship changes.

For example, if a SalesOrderDetail affects the searchable representation of a SalesOrder:

class SalesOrderDetailObserver
{
    public function saved(SalesOrderDetail $detail): void
    {
        $detail->salesOrder?->searchable();
    }

    public function deleted(SalesOrderDetail $detail): void
    {
        $detail->salesOrder?->searchable();
    }
}

Now the flow becomes:

SalesOrderDetail changes

Find affected SalesOrder

Rebuild toSearchableArray()

Send updated document

Meilisearch index is updated

The exact implementation will depend on the relationship and the application.

For a large system, immediately reindexing models inside every observer may not always be the best solution. It may be better to dispatch jobs, batch updates, or debounce repeated indexing operations.

But the important lesson is not the observer itself.

It is recognizing the dependency.

If this:

public function toSearchableArray(): array
{
    return [
        'id' => $this->id,
        'product_ids' => $this->details->pluck('product_id')->all(),
    ];
}

depends on SalesOrderDetail, then changes to SalesOrderDetail can invalidate the SalesOrder search document.

That dependency needs to be handled somewhere.

Think About the Search Document, Not the Database Tables

This was probably the biggest mental-model change for me.

When working primarily with MySQL and Eloquent, I naturally think in terms of tables and relationships:

sales_orders
sales_order_details
products
warehouses
channels

When working with Meilisearch, it is more useful to think about the document that represents the thing being searched:

{
  "id": 12345,
  "order_number": "SO-2608-00123",
  "status": "READY",
  "channel": "Shopee",
  "product_ids": [1001, 1002],
  "warehouse_ids": [10, 12]
}

Some of those values may come directly from sales_orders.

Others may come from several related tables.

Meilisearch does not care where they came from.

It only cares about the final document.

That means designing a Meilisearch index is less about mirroring your database schema and more about asking:

What information does this document need so that the application can search and filter it without going back to relational queries?

Once I started thinking about it that way, decisions around toSearchableArray() and filterableAttributes became much easier.

Database and Search Index Have Different Responsibilities

Another useful distinction is that Meilisearch should not become the source of truth for application data.

The database still owns the actual SalesOrder and its relationships.

Meilisearch contains a representation optimized for searching.

I think of it roughly like this:

                MySQL
          Source of Truth

                 │ indexing

            Meilisearch
        Search Representation

                 │ search/filter

             Application

This also means the Meilisearch index should be rebuildable.

If the index becomes corrupted, outdated, or is accidentally deleted, the application should be able to reconstruct it from the database.

That is an important property because synchronization problems will eventually happen.

A queue worker might stop.

Meilisearch might temporarily become unavailable.

A deployment might happen while indexing jobs are still running.

Or a developer might add a new relationship field to toSearchableArray() without realizing that existing documents need to be reindexed.

The database remains the canonical state.

The search index is derived state.

What I Learned

Using Meilisearch with Laravel Scout is easy to start with, but there are a few important details once it becomes part of a real application’s query flow.

The main lessons I took away were:

  1. Eloquent and Scout are separate query paths.
    SalesOrder::where() still queries the database. SalesOrder::search() goes through Scout and the configured search engine.

  2. Indexed does not automatically mean filterable.
    Fields used for filtering need to be configured as filterableAttributes.

  3. Meilisearch doesn’t understand Laravel relationships.
    Data required from related models needs to be included in the indexed document.

  4. Search documents are naturally denormalized.
    Instead of reproducing database relationships, build documents around the queries the application needs to perform.

  5. Relationship changes can make an index stale.
    If the searchable representation depends on related models, changes to those models may need to trigger reindexing.

  6. The database remains the source of truth.
    Meilisearch should be treated as a rebuildable search representation of the underlying data.

The integration itself wasn’t the difficult part.

The more important part was understanding where the responsibility of the relational database ends and where the responsibility of the search index begins.

References