Building a Site Search Engine with Laravel and Meilisearch

April 9, 2025

development

Ever tried implementing search on your Laravel site and ended up with slow, irrelevant results? Traditional search solutions often require complex setup, extensive configuration, and still fall short when it comes to understanding what users are actually looking for. That's where Meilisearch comes in.

I've been building search functionality for years, from simple database queries to complex Elasticsearch implementations. Recently, I've been using Meilisearch more and more because it solves many of the pain points I've encountered with other search solutions. It's fast, easy to set up, and just works.

Note: Looking for alternative search implementations? Check out my guides on building a hybrid search system with PostgreSQL and building a modern site search engine with Elasticsearch.

Here's why I keep coming back to Meilisearch for my Laravel projects:

A Real-World Example

Let me show you what I mean with a practical example. Say a user searches for "how to use laravel scout" on your site:

Traditional Search Results (without Meilisearch):

  1. "Laravel Scout Documentation" (exact match)
  2. "Using Laravel Scout" (partial match)
  3. "Laravel Search Implementation" (related but not specific)

Meilisearch Results (with typo tolerance and semantic understanding):

  1. "Laravel Scout Documentation" (exact match)
  2. "Getting Started with Laravel Scout" (semantic match)
  3. "Implementing Search with Laravel Scout" (semantic match)
  4. "Laravel Scout: A Complete Guide" (semantic match)
  5. "How to Use Laravel Scout for Search" (semantic match)

The difference is night and day. Meilisearch:

Getting Started

I've set up search systems many times, and Meilisearch is by far the easiest to get running. Let me walk you through it:

1. Project Setup

First, let's create a new Laravel project. I like to keep my search implementations in their own project to start:

composer create-project laravel/laravel sitesearch
cd sitesearch

2. Install Required Packages

Here's where Meilisearch really shines - you only need two packages:

composer require laravel/scout
composer require meilisearch/meilisearch-php

Tip: If you're coming from my Elasticsearch guide, you'll notice how much simpler this setup is compared to Elasticsearch's requirements.

3. Publish Scout Configuration

Generate the Scout configuration file - this is a one-time setup:

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

4. Configure Environment Variables

Add these to your .env file. Unlike Elasticsearch or PostgreSQL, there's no complex configuration needed:

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

Setting Up Meilisearch

1. Install Meilisearch

For local development, I prefer using Docker. It's a one-liner compared to the setup required for Elasticsearch or PostgreSQL with pgvector:

docker run -d --name meilisearch -p 7700:7700 -e MEILI_MASTER_KEY=masterKey getmeili/meilisearch:latest

2. Verify Installation

Check if Meilisearch is running:

curl http://localhost:7700/health

Implementing Search

1. Make Models Searchable

Here's where things get interesting. Unlike my PostgreSQL implementation which required vector embeddings, or my Elasticsearch setup which needed complex mappings, Meilisearch keeps it simple:

use Laravel\Scout\Searchable;

class Page extends Model
{
    use Searchable;

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

2. Configure Searchable Fields

This is where you can really fine-tune your search. Unlike Elasticsearch's complex scoring system or PostgreSQL's hybrid approach, Meilisearch makes it straightforward:

public function toSearchableArray()
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'content' => $this->content,
        'url' => $this->url,
        '_searchableAttributes' => ['title', 'content'],
        '_rankingRules' => [
            'words',
            'typo',
            'proximity',
            'attribute',
            'sort',
            'exactness'
        ],
        '_customRanking' => ['desc(created_at)']
    ];
}

3. Create Search Controller

The search controller is where you'll spend most of your time. I've built many search implementations, and Meilisearch's API is by far the most intuitive:

class SearchController extends Controller
{
    public function search(Request $request)
    {
        $query = $request->input('q');
        $results = Page::search($query)
            ->when($request->has('site_id'), function ($query) use ($request) {
                return $query->where('site_id', $request->site_id);
            })
            ->paginate(10);

        return response()->json($results);
    }
}

Advanced Features

1. Custom Ranking Rules

One of my favorite features of Meilisearch is how easy it is to customize ranking rules. Compare this to the complex scoring system in Elasticsearch or the hybrid approach with PostgreSQL:

public function toSearchableArray()
{
    return [
        // ... other fields ...
        '_rankingRules' => [
            'words',
            'typo',
            'proximity',
            'attribute',
            'sort',
            'exactness'
        ],
        '_customRanking' => ['desc(created_at)']
    ];
}

2. Filtering and Faceting

Filtering in Meilisearch is much simpler than in Elasticsearch or PostgreSQL:

public function search(Request $request)
{
    $query = $request->input('q');
    $results = Page::search($query)
        ->when($request->has('category'), function ($query) use ($request) {
            return $query->where('category', $request->category);
        })
        ->when($request->has('date_range'), function ($query) use ($request) {
            return $query->where('created_at', '>=', $request->date_range);
        })
        ->paginate(10);

    return response()->json($results);
}

3. Typo Tolerance

Configure typo tolerance in your model:

public function toSearchableArray()
{
    return [
        // ... other fields ...
        '_typoTolerance' => [
            'enabled' => true,
            'minWordSizeForTypos' => 4,
            'disableOnWords' => ['laravel', 'meilisearch']
        ]
    ];
}

4. Multi-tenant Search

Implement multi-tenant search for multiple sites:

class SearchController extends Controller
{
    public function search(Request $request)
    {
        $query = $request->input('q');
        $siteId = $request->input('site_id');

        $results = Page::search($query)
            ->where('site_id', $siteId)
            ->when($request->has('filters'), function ($query) use ($request) {
                foreach ($request->filters as $field => $value) {
                    $query->where($field, $value);
                }
            })
            ->paginate(10);

        return response()->json([
            'results' => $results,
            'facets' => $this->getFacets($results),
            'suggestions' => $this->getSuggestions($query)
        ]);
    }
}

5. Search Suggestions

Implement search suggestions:

class SearchController extends Controller
{
    public function suggestions(Request $request)
    {
        $query = $request->input('q');

        return Page::search($query)
            ->take(5)
            ->get()
            ->map(function ($page) {
                return [
                    'title' => $page->title,
                    'url' => $page->url,
                    'snippet' => Str::limit($page->content, 100)
                ];
            });
    }
}

6. Custom Search Settings

Configure custom search settings for different content types:

class Page extends Model
{
    use Searchable;

    public function searchableSettings()
    {
        return [
            'attributesToSearch' => ['title', 'content', 'description'],
            'attributesToRetrieve' => ['id', 'title', 'url', 'snippet'],
            'attributesToHighlight' => ['title', 'content'],
            'attributesToCrop' => ['content'],
            'cropLength' => 200,
            'highlightPreTag' => '<mark>',
            'highlightPostTag' => '</mark>'
        ];
    }
}

Performance Optimization

1. Batch Indexing

Use batch indexing for large datasets:

Page::where('status', 'published')->searchable();

2. Selective Indexing

Only index necessary fields:

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

3. Caching

Implement caching for frequently accessed searches:

public function search(Request $request)
{
    $cacheKey = 'search:' . md5($request->fullUrl());

    return Cache::remember($cacheKey, now()->addMinutes(60), function () use ($request) {
        $query = $request->input('q');
        return Page::search($query)
            ->paginate(10);
    });
}

Deployment Considerations

1. Production Configuration

Update your production environment variables:

MEILISEARCH_HOST=https://your-meilisearch-instance.com
MEILISEARCH_KEY=your-production-key

2. Scaling

Consider these scaling strategies:

3. Monitoring

Set up monitoring for your Meilisearch instance:

class MeilisearchHealthCheck
{
    public function check()
    {
        try {
            $client = app(Meilisearch::class);
            $health = $client->health();

            return [
                'healthy' => $health['status'] === 'available',
                'status' => $health['status']
            ];
        } catch (\Exception $e) {
            return [
                'healthy' => false,
                'error' => $e->getMessage()
            ];
        }
    }
}

Troubleshooting

Common issues and solutions:

  1. Indexing Problems

    • Check Meilisearch logs
    • Verify model configuration
    • Ensure proper field mapping
  2. Search Relevance

    • Adjust ranking rules
    • Configure typo tolerance
    • Optimize field weights
  3. Performance Issues

    • Monitor response times
    • Check resource utilization
    • Implement caching

Performance Comparison

I've used all three major search solutions in production, and here's what I've found:

Feature Meilisearch Elasticsearch PostgreSQL
Setup Time ⚡ Minutes ⚡⚡ Hours ⚡⚡ Days
Query Speed ⚡ <50ms ⚡⚡ 100-200ms ⚡⚡⚡ 200-500ms
Memory Usage ⚡ Low ⚡⚡⚡ High ⚡⚡ Moderate
Typo Tolerance ✅ Built-in ⚠️ Requires configuration ❌ Limited
Real-time Updates ✅ Instant ⚠️ Near real-time ⚠️ Near real-time
Scalability ✅ Easy ⚠️ Complex ⚠️ Complex
Maintenance ✅ Minimal ⚠️ High ⚠️ Moderate

Real-World Performance Metrics

In my experience with a production site containing 100,000 documents:

  1. Indexing Speed

    • Meilisearch: ~10,000 documents/second
    • Elasticsearch: ~5,000 documents/second
    • PostgreSQL: ~1,000 documents/second
  2. Query Response Time

    • Meilisearch: 10-50ms
    • Elasticsearch: 100-200ms
    • PostgreSQL: 200-500ms
  3. Memory Usage

    • Meilisearch: ~1GB
    • Elasticsearch: ~4GB
    • PostgreSQL: ~2GB

Note: These numbers are from my own testing. For more detailed performance comparisons, check out my Elasticsearch guide and PostgreSQL guide.

When to Choose Meilisearch

After working with all three solutions, here's when I recommend using Meilisearch:

  1. You need search fast: When you need to implement search quickly without complex setup
  2. Resources are limited: When you're working with limited server resources
  3. Real-time updates matter: When you need instant search results after content changes
  4. Typo tolerance is important: When you want built-in fuzzy search without configuration
  5. You're using Laravel: When you want seamless integration with Laravel Scout

Compare Approaches: For more detailed comparisons, see my guides on Elasticsearch-based search and hybrid search with PostgreSQL.

Here are some things I've learned the hard way:

  1. Memory Usage: While Meilisearch is light, it still needs enough RAM for your index. I recommend at least 1GB per million documents.
  2. Index Size: Keep your index size in check. I've found that pruning old content helps maintain performance.
  3. Rate Limiting: Meilisearch can handle high loads, but you should still implement rate limiting for your API endpoints.
  4. Backup Strategy: Always have a backup strategy. I use daily snapshots of my Meilisearch data.

Conclusion

After years of working with different search solutions, Meilisearch has become my go-to choice for most Laravel projects. It's not just about the speed (though that's impressive) - it's about the developer experience. The simplicity of setup, the intuitive API, and the built-in features make it a joy to work with.

That said, it's not always the right choice. For complex search needs with AI integration, I still reach for my PostgreSQL with pgvector solution. And for enterprise-scale search with complex analytics, Elasticsearch remains a solid choice.

The key is understanding your requirements and choosing the right tool for the job. For most Laravel applications, Meilisearch hits the sweet spot between power and simplicity.

Resources