Building a Site Search Engine with Laravel and Meilisearch

April 9, 2025

development

I've built search with database queries and Elasticsearch. I've also been using Meilisearch more often in Laravel projects. Scout gives it a familiar entry point: make a model searchable, decide which fields to index, and query it from the application.

For this example, the searchable model is a website page. I want to index its title and content, keep its URL for the result, and scope searches to a site.

Get Scout connected

In an existing Laravel app, install the packages and publish Scout's configuration:

composer require laravel/scout meilisearch/meilisearch-php http-interop/http-factory-guzzle
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

You'll also need a running Meilisearch instance. Set the connection in .env using the host and key for that instance:

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

Use compatible versions of the server and client. The Scout documentation covers setup and the Meilisearch integration.

Choose the document fields

Here is the relevant part of a Page model, assuming the table already has these columns. Merge it with any other model behavior in your app:

namespace App\Models;

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

class Page extends Model
{
    use Searchable;

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

That's the document. Index settings are configured separately. Putting _rankingRules or _searchableAttributes in this array would just send extra document fields; it isn't how Scout configures the index.

In config/scout.php, merge this entry into the existing Meilisearch configuration:

'index-settings' => [
    App\Models\Page::class => [
        'searchableAttributes' => ['title', 'content'],
        'filterableAttributes' => ['site_id'],
    ],
],

Then sync the settings and import the existing pages:

php artisan scout:sync-index-settings
php artisan scout:import "App\Models\Page"

Keep the fields aligned. If the application filters on site_id, that field needs to be in the indexed document and configured as filterable.

Search the right site

After resolving and authorizing the site in the application, the search can be as small as:

$results = Page::search($query)
    ->where('site_id', $site->id)
    ->paginate(10);

The filter limits results to that site's pages. It doesn't replace authorization, and the site ID shouldn't come from an unchecked request parameter.

Typo tolerance is one reason this is useful for a search box. Someone can misspell a word and still get candidates. Meilisearch has settings for that behavior, including cases where you want it disabled.

This setup is keyword search with typo tolerance. Semantic search requires additional configuration; it doesn't appear just because you installed Scout.

Indexing takes time

Scout tracks model changes, but Meilisearch processes index updates asynchronously. Saving a page doesn't mean it will appear in the next search immediately. If indexing is queued in Laravel, the queue workers need to be running too.

I'd test the searches people actually make against the content in the index before tuning ranking. A title-heavy documentation site and a catalog with short product names aren't the same problem. Neither is well described by a universal latency table.

For other approaches, see SiteSearch with Elasticsearch and hybrid search in PostgreSQL. This is the version I reach for when the Scout integration and keyword-search features fit what the app needs.