Laravel Scout Meilisearch

Instant Search in Laravel: Implementing Laravel Scout and Meilisearch

The Problem With LIKE Queries (And Why You Should Care)

LIKE '%laravel%' works fine when your posts table has 300 rows and your local machine is yawning through the load. Ship it to production with 500,000 records, a few concurrent users, and a product manager who wants autocomplete — and you’ll be staring at full-table scans, spiking database CPU, and a UX that makes your application feel like it was built in 2009.

Laravel Scout Meilisearch is the production answer. Scout is Laravel’s first-party search abstraction: a clean PHP interface that sits inside your application and coordinates indexing and query delegation. Meilisearch is the engine underneath it — a Rust-powered, open-source search server that delivers sub-50ms responses, built-in typo tolerance, and configurable ranking rules. Together, they give you the kind of instant search your users have come to expect from modern SaaS products, without touching your database on every keystroke.

This guide covers the full implementation in Laravel 11 and 12: Docker-based local setup, Eloquent model preparation, search-as-you-type with both Livewire and InstantSearch.js, result refinement, and production queue strategy. No legacy app/Http/Kernel.php patterns. No hand-waving on the parts that actually break in production.

The Mental Model: Scout as Driver, Meilisearch as Engine

Before writing a line of code, get this distinction locked in — it will save you hours of confusion later.

Scout does not perform the search itself. It provides a consistent PHP interface — Model::search('query') — and delegates the actual work to whichever engine you’ve configured. That engine, Meilisearch in our case, maintains its own index: a denormalized, fully optimized snapshot of your data that lives completely outside your database. The same abstraction principle — your application code talks to an interface, not a vendor — is the foundation of the production-grade AI architecture guide.

The data flow is straightforward:

  1. You create or update an Eloquent model.
  2. Scout detects the change via model observers it registers automatically.
  3. Scout pushes the record to the Meilisearch index, either synchronously or via a queued job.
  4. When a user searches, your application queries Meilisearch directly — your database is never touched.
  5. Meilisearch returns ranked, typo-tolerant results in milliseconds.

Your database handles persistence. Meilisearch handles retrieval. That separation is the architectural win. It also means your search performance scales independently of your database — you can tune Meilisearch resources without touching your primary database server.

Setting Up Laravel Scout Meilisearch: Engine Configuration

Running Meilisearch Locally with Docker Compose

Meilisearch is an external service — it does not run inside your Laravel application. The cleanest way to run it locally is Docker Compose. Add the following service to your docker-compose.yml:

services:
  meilisearch:
    image: getmeili/meilisearch:latest
    ports:
      - "7700:7700"
    environment:
      MEILI_MASTER_KEY: "masterKey"
      MEILI_ENV: "development"
    volumes:
      - meilisearch_data:/meili_data

volumes:
  meilisearch_data:

Start it with docker compose up -d. Meilisearch’s web dashboard will be available at http://localhost:7700. If you’re running Laravel Sail, fold this service directly into Sail’s docker-compose.yml so your entire stack stays in one place.

If Docker isn’t your preference, Meilisearch also distributes a standalone binary you can run directly. The Meilisearch installation documentation covers every platform.

Installing the Required Packages

composer require laravel/scout meilisearch/meilisearch-php http-interop/http-factory-guzzle

Three packages, each with a distinct role. laravel/scout provides the Laravel integration layer and the Searchable trait. meilisearch/meilisearch-php is the official PHP SDK that Scout uses to communicate with Meilisearch’s HTTP API. http-interop/http-factory-guzzle satisfies the PSR-17 HTTP factory dependency the SDK requires — skip it and you’ll get a confusing runtime exception with no obvious stack trace pointing to the missing factory.

Publish the Scout configuration file:

php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

This drops a config/scout.php file into your project. You’ll rarely need to edit it directly, but it’s useful to have visible for environment-specific overrides.

Configuring Your Environment

Open your .env file and add:

SCOUT_DRIVER=meilisearch
SCOUT_QUEUE=true
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_KEY=masterKey

The SCOUT_QUEUE=true line deserves its own section — we’ll address that in detail later. For now, just know it belongs here. In production, MEILISEARCH_KEY should be a strong, randomly generated master key stored in your secrets manager. masterKey is for local development only.

Preparing Your Eloquent Models

Adding the Searchable Trait

Any Eloquent model becomes searchable with a single trait import:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Post extends Model
{
    use Searchable;

    protected $fillable = ['title', 'body', 'author_id', 'status', 'published_at'];
}

That’s the minimum viable setup. But minimum viable is not production-ready.

Controlling the Index Shape with toSearchableArray()

By default, Scout calls toArray() on your model and ships the result to Meilisearch. Every column. Including password, remember_token, internal_notes, stripe_customer_id, and any other sensitive field you have on that model. Never let this default ship to production.

Override toSearchableArray() to define exactly what the search index receives:

public function toSearchableArray(): array
{
    $this->loadMissing(['user', 'tags']);

    return [
        'id'           => $this->id,
        'title'        => $this->title,
        'body'         => $this->body,
        'author'       => $this->user?->name,
        'tags'         => $this->tags->pluck('name')->toArray(),
        'published_at' => $this->published_at?->timestamp,
        'status'       => $this->status,
    ];
}

A few intentional decisions here. We store published_at as a Unix timestamp rather than a formatted date string — Meilisearch can use numeric fields for range filtering and sorting, which unlocks queries like “search only within the last 30 days.” We pull in the related author name and tags using loadMissing() to avoid N+1 queries during bulk import operations.

[Architect’s Note] toSearchableArray() is the contract between your domain model and your search index. Treat it with the same discipline you’d give a public API response. Write a dedicated unit test that asserts the exact shape and values this method returns — index shape drift, where a developer adds a column without updating this method, is a silent bug. Meilisearch accepts the data without complaint, your index just starts serving incomplete or incorrect results, and there’s no error to alert you. Test it. Version it. Review it in pull requests.

Controlling Which Records Are Indexed

You don’t want draft posts, soft-deleted records, or unpublished content showing up in search results. Use shouldBeSearchable():

public function shouldBeSearchable(): bool
{
    return $this->status === 'published'
        && $this->published_at?->isPast();
}

Scout checks this method before indexing. If it returns false, the record is excluded from the index — or removed if it was previously indexed. This is far cleaner than filtering on the frontend, and it prevents you from accidentally leaking unpublished or sensitive content through the search API.

Running the Initial Import

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

For tables with more than a few thousand rows, make sure SCOUT_QUEUE=true is set before running this. Scout will dispatch batched MakeSearchable jobs through your queue system rather than trying to push everything in a single synchronous HTTP call. Your queue workers handle the rest.

Building the Search-as-You-Type Frontend

This is where the implementation pays off visually. Two approaches, two different trade-offs. Pick based on your traffic expectations and team stack.

Option A — Livewire: The Laravel-Native Approach

Livewire is the natural fit for Laravel-native instant search. One component, no custom API endpoint, no CORS configuration, no JavaScript state management.

<?php

namespace App\Livewire;

use App\Models\Post;
use Livewire\Component;

class SearchPosts extends Component
{
    public string $search = '';

    public function render()
    {
        $results = strlen($this->search) >= 2
            ? Post::search($this->search)
                ->where('status', 'published')
                ->get()
            : collect();

        return view('livewire.search-posts', ['results' => $results]);
    }
}
<!-- resources/views/livewire/search-posts.blade.php -->
<div>
    <input
        type="text"
        wire:model.live.debounce.300ms="search"
        placeholder="Search posts..."
        class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
    />

    <ul class="mt-4 space-y-2">
        @forelse($results as $post)
            <li class="p-4 bg-white rounded-lg shadow-sm border border-gray-100">
                <a href="{{ route('posts.show', $post) }}" class="font-semibold text-indigo-600 hover:underline">
                    {{ $post->title }}
                </a>
                <p class="text-sm text-gray-500 mt-1">{{ $post->author }}</p>
            </li>
        @empty
            @if(strlen($search) >= 2)
                <li class="text-gray-400 text-sm py-2">No results found for "{{ $search }}".</li>
            @endif
        @endforelse
    </ul>
</div>

wire:model.live.debounce.300ms fires a server round-trip 300ms after the user stops typing. Each request hits your Laravel application, which calls Scout, which queries Meilisearch, and returns rendered HTML. Zero client-side state. If you’ve already worked with Livewire’s reactive model for real-time interfaces — as demonstrated in the Laravel Livewire Claude API tutorial — this pattern will feel immediately familiar. The tradeoff is latency under load: each debounced keystroke is a network round-trip to your server. For moderate traffic, this is entirely acceptable. For high-concurrency search scenarios, read on.

Option B — InstantSearch.js: The High-Performance Approach

Meilisearch maintains an official InstantSearch.js adapter — a JavaScript library originally developed by Algolia that Meilisearch supports natively. With this approach, search requests go directly from the browser to Meilisearch, bypassing your Laravel server entirely. Your web workers stay free.

npm install instantsearch.js @meilisearch/instant-meilisearch
// resources/js/search.js
import instantsearch from 'instantsearch.js';
import { searchBox, hits, highlight } from 'instantsearch.js/es/widgets';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';

const searchClient = instantMeiliSearch(
    import.meta.env.VITE_MEILISEARCH_HOST,
    import.meta.env.VITE_MEILISEARCH_SEARCH_KEY // Search-only key — NEVER the master key
);

const search = instantsearch({
    indexName: 'posts',
    searchClient,
});

search.addWidgets([
    searchBox({
        container: '#searchbox',
        placeholder: 'Search posts...',
    }),
    hits({
        container: '#hits',
        templates: {
            item: (hit, { html }) => html`
                <article class="hit-item">
                    <h3>${hit._highlightResult.title.value}</h3>
                    <p class="hit-author">${hit.author}</p>
                    <p class="hit-excerpt">${hit._highlightResult.body?.value ?? ''}</p>
                </article>
            `,
        },
        cssClasses: {
            list: 'hits-list',
            item: 'hit-card',
        },
    }),
]);

search.start();

The _highlightResult object is populated by Meilisearch. It wraps matched terms in <em> tags automatically — that’s the highlighted keyword effect you see in every modern search UI, and you get it without writing a single line of custom highlighting logic.

One hard rule: expose only a search-only API key scoped to specific indexes in your frontend JavaScript. Generate scoped keys through the Meilisearch API or the meilisearch-php SDK. Your master key never touches a .env file that gets compiled into frontend assets.

Refining Search Results

Returning results is the easy part. Returning the right results in the right order requires deliberate configuration.

Configuring Searchable Attributes and Ranking Rules

Meilisearch applies a default ranking rule chain — words, typo, proximity, attribute, sort, exactness — and the attribute rule uses your searchable attributes order to break ties. A match in title outranks a match in body if you configure it that way.

Wire this up in a service provider or a dedicated artisan command:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Meilisearch\Client;

class SearchServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if ($this->app->runningInConsole() || app()->isProduction()) {
            return;
        }

        /** @var Client $client */
        $client = $this->app->make(Client::class);

        $client->index('posts')->updateSettings([
            'searchableAttributes' => [
                'title',   // Highest relevance
                'tags',
                'author',
                'body',    // Lowest relevance
            ],
            'filterableAttributes' => [
                'status',
                'published_at',
            ],
            'sortableAttributes' => [
                'published_at',
            ],
            'rankingRules' => [
                'words',
                'typo',
                'proximity',
                'attribute',
                'sort',
                'exactness',
            ],
        ]);
    }
}

Register this in bootstrap/app.php using Laravel 11’s application builder pattern rather than the old provider array in config/app.php:

// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withProviders([
        App\Providers\SearchServiceProvider::class,
    ])
    // ...
    ->create();

For most applications, move this index configuration into a dedicated artisan command — php artisan search:configure — and run it as part of your deployment pipeline. Service providers are not the right place for external API calls that depend on network availability.

Typo Tolerance in Practice

Meilisearch’s typo tolerance is one of its strongest selling points. A user searching "Lravel Scout" finds "Laravel Scout". The engine calculates edit distance between the query and indexed terms and applies tolerance thresholds based on word length.

You can tighten the defaults for fields where precision matters — SKUs, usernames, short codes:

$client->index('posts')->updateTypoTolerance([
    'enabled' => true,
    'minWordSizeForTypos' => [
        'oneTypo'  => 5,
        'twoTypos' => 9,
    ],
    'disableOnAttributes' => ['tags'], // Exact tag matching only
]);

Filtering and Scoped Search

Because we stored published_at as a Unix timestamp and declared it as a filterable attribute, range-based filtering becomes trivial:

$results = Post::search($query)
    ->options([
        'filter' => sprintf(
            'status = published AND published_at > %d',
            now()->subDays(30)->timestamp
        ),
        'sort' => ['published_at:desc'],
    ])
    ->paginate(20);

This is exactly why your toSearchableArray() decisions matter upstream. The data shape you define there determines every filtering and sorting capability you have available at query time.

Production Considerations: Queues, Syncing, and Availability

[Production Pitfall] SCOUT_QUEUE defaults to false. That means every Model::create(), Model::update(), and Model::delete() writes synchronously to Meilisearch before your controller returns a response. On any write-heavy application — or any properly configured production environment — this adds measurable latency to every mutation and creates a hard coupling between your web workers and an external HTTP service. If Meilisearch is unavailable for 30 seconds — a routine restart, a network hiccup, a deployment — your entire write path will slow to a crawl or start throwing unhandled exceptions. Set SCOUT_QUEUE=true in production. This is not optional.

With queue-backed indexing enabled, Scout dispatches MakeSearchable and RemoveFromSearch jobs through Laravel’s queue system. Your web request returns immediately. Your dedicated queue workers handle the Meilisearch sync asynchronously. The search index may trail your database by a few seconds — that is an acceptable and expected trade-off for virtually every production use case.

If you have a model that needs to exclude soft-deleted records automatically, use the SoftDeletes trait alongside Searchable. Scout detects the SoftDeletes trait and automatically removes soft-deleted records from the index when delete() is called.

For production Meilisearch hosting, your options are: Meilisearch Cloud for managed infrastructure with zero operational overhead, a self-hosted container on your existing VPS or Kubernetes cluster, or a dedicated server provisioned alongside your Laravel stack. If you’re managing your own infrastructure and need a solid reference for how to structure your Laravel deployment alongside services like Meilisearch — covering zero-downtime strategies, Nginx configuration, and environment management — the complete Laravel production deployment guide is the right starting point.

Testing Your Scout Integration

Don’t skip this. Scout ships with a testing fake driver — SCOUT_DRIVER=testing or Scout::fake() in test setup — that prevents real HTTP calls to Meilisearch during your test suite.

use Laravel\Scout\Scout;

public function test_post_is_queued_for_indexing_on_publish(): void
{
    Scout::fake();

    $post = Post::factory()->create(['status' => 'draft']);
    
    $post->update(['status' => 'published', 'published_at' => now()]);

    Scout::assertQueued(Post::class, function ($model) use ($post) {
        return $model->id === $post->id;
    });
}

Also write a test for toSearchableArray(). Assert the exact array structure it returns and assert that sensitive fields are absent. This test will protect you from the index shape drift we discussed earlier — it’s cheap to write and has saved more than one production search index from serving corrupted data after a migration.

Meilisearch vs Algolia vs Typesense: Choosing Your Engine

Meilisearch is not the only Scout-compatible engine worth knowing. Algolia is the category incumbent — polished dashboard, enterprise SLAs, and a mature InstantSearch.js library that Meilisearch deliberately mirrors. The trade-off is cost: Algolia’s pricing scales aggressively with record count and search operations, and it becomes expensive fast on content-heavy applications. For most self-funded or early-stage Laravel products, you’ll hit a painful billing threshold long before you actually need what Algolia exclusively offers. If your use case requires semantic similarity search rather than keyword retrieval — matching on meaning rather than terms — that is the domain of vector databases and RAG. The vector database and RAG implementation in Laravel guide covers that architecture.

MeilisearchAlgoliaTypesense
HostingSelf-hosted or managed cloudManaged onlySelf-hosted or managed cloud
Laravel Scout Driver✅ Official✅ Official✅ Community
PricingFree (self-hosted)Expensive at scaleFree (self-hosted)
Typo Tolerance✅ Built-in✅ Built-in✅ Built-in
Setup ComplexityLowLowMedium
Schema EnforcementFlexibleFlexibleStrict
Memory FootprintMediumN/A (managed)Low
Best ForMost Laravel appsEnterprise / SLA-requiredMemory-constrained / strict schemas
InstantSearch.js Support✅ Native✅ Native (original)✅ Compatible

Typesense is the closest open-source competitor to Meilisearch — also self-hostable, with strong typo tolerance and a solid Laravel Scout driver. Where Typesense edges ahead is strict schema enforcement and a slightly lower memory footprint at scale. Where Meilisearch wins is developer experience, forgiving out-of-the-box defaults, and a setup curve that doesn’t require a PhD in search theory. For most Laravel applications — a SaaS product, an e-commerce catalogue, a docs search — Meilisearch is the right default. Move to Algolia when you need managed infrastructure with contractual uptime guarantees and have the budget. Reach for Typesense when memory efficiency and rigid schema control are hard requirements.

The Takeaway

Scout is the driver. Meilisearch is the engine. Keep that distinction clear — it matters when you’re debugging, when you’re spinning up a new environment, and when you eventually evaluate whether Typesense, Elasticsearch, or Algolia better fits a future project. The driver abstraction means swapping engines requires a config change, not a rewrite.

The structure we’ve built is deliberate:

  • toSearchableArray() owns the index contract and is tested
  • shouldBeSearchable() controls index membership and keeps sensitive content out
  • Queue-backed indexing decouples writes from Meilisearch availability
  • Index configuration lives in a versioned artisan command, not scattered across service providers
  • Livewire handles moderate-traffic search; InstantSearch.js handles high-concurrency scenarios

This isn’t a proof-of-concept. It’s a structure you can ship, monitor, and extend. Add faceted navigation, geo-search, multi-index federated search — Meilisearch supports all of it, and Scout gives you the PHP hooks to wire it cleanly into your Laravel Service Container.

Refer to the official Laravel Scout documentation and the Meilisearch documentation as your authoritative references. Read the ranking rule documentation in particular — relevance tuning is where good search becomes great search, and it’s almost always underinvested.

Frequently Asked Questions

What is the difference between Laravel Scout and Meilisearch?

Laravel Scout is the search abstraction layer built into Laravel — it provides a consistent PHP interface (Model::search()) and handles model observer registration and queue dispatch. Meilisearch is the search engine itself: a separate service that stores your search index and executes queries. Scout is the driver; Meilisearch is the engine. You can swap Meilisearch out for Algolia or Typesense without changing your application code — only your .env configuration changes.

Do I need to run Meilisearch on my own server?

Not necessarily. Meilisearch offers a managed cloud product at meilisearch.com/cloud that removes all operational overhead. For local development, Docker Compose is the easiest option. For production, you can self-host Meilisearch on your existing VPS or Kubernetes cluster, or use the managed cloud depending on your infrastructure preferences and budget.

Will Meilisearch stay in sync with my database automatically?

Yes — Scout registers Eloquent model observers that fire on created, updated, and deleted events. Any change to a searchable model is automatically pushed to the Meilisearch index. With SCOUT_QUEUE=true, this sync happens asynchronously via your queue workers, meaning your search index may trail your database by a few seconds. That is the expected and recommended behaviour in production.

Is it safe to expose my Meilisearch API key in frontend JavaScript?

Only if you use a search-only key scoped to specific indexes — never your master key. Meilisearch supports tenant tokens and scoped API keys that restrict access to read-only search operations on designated indexes. Generate a scoped key via the Meilisearch SDK or API and use that exclusively in client-side code. Your master key belongs only in server-side environment variables.

Should I use Livewire or InstantSearch.js for search-as-you-type?

It depends on your traffic. Livewire is the simpler, Laravel-native approach — each debounced keystroke triggers a server round-trip that queries Meilisearch and returns rendered HTML. For moderate traffic this works well. InstantSearch.js sends search requests directly from the browser to Meilisearch, bypassing your Laravel server entirely — your web workers are never touched. For high-concurrency search or applications where search is a core feature, InstantSearch.js is the better choice.

What happens to my Meilisearch index when I run a database migration?

Nothing automatically — and that is the risk. If you add, rename, or remove columns that affect your toSearchableArray() output, your index shape drifts silently. Meilisearch accepts whatever you send without validation errors. The fix is to treat toSearchableArray() as a versioned contract, test its exact output in your test suite, and re-run php artisan scout:import after any migration that changes the indexed data shape.

How do I exclude draft or private records from search results?

Override the shouldBeSearchable() method on your model and return false for any record that should not be indexed. Scout checks this method before indexing and will also remove previously indexed records that no longer qualify. This is the correct approach — filtering on the frontend after retrieval is unreliable and risks leaking content that should never have been returned.

Does Laravel Scout support pagination with Meilisearch?

Yes. Scout’s paginate() method works directly with Meilisearch and integrates with Laravel’s standard paginator. Call Post::search($query)->paginate(20) and use {{ $results->links() }} in your Blade view exactly as you would with an Eloquent query. Meilisearch handles the offset and limit internally; Scout translates the paginator interface into the appropriate API parameters.

Subscribe
Notify of
2 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Conrad Müller

Laravel Scout aren’t not syncing records to Meilisearch index after adding Searchable trait. I’ve followed the steps to install the driver and add the trait to my model, but when I create new records in my database, they don’t appear in my Meilisearch dashboard (running on localhost:7700).

My Setup:

Model (app/Models/Post.php)

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Post extends Model
{
    use Searchable;

    public function toSearchableArray()
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'content' => $this->content,
        ];
    }
}

.env config

SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey

My issue is that when I run Post::create([...]) in Tinker, the record is saved to the db, but the Meilisearch index remains empty. I’ve verified that the Meilisearch server is running and accessible.

If I run php artisan scout:import "App\Models\Post", the records do show up. However, subsequent updates or creates do not sync automatically.

I’ve tried restarting the Meilisearch container, clearing config cache: php artisan config:clear, verified meilisearch/meilisearch-php is installed via Composer.

Am I missing a specific configuration or a “listener” that needs to be active for Scout to detect Eloquent changes?

Navigation
Scroll to Top