Meilisearch in Laravel: Full-Text Search Without the Elasticsearch Tax
I used to reach for Elasticsearch any time a project needed search. Then I'd spend three days configuring it, mapping fields, tuning analyzers, and wondering why it was consuming 2GB of RAM to index 5
Meilisearch in Laravel: Full-Text Search Without the Elasticsearch Tax
I used to reach for Elasticsearch any time a project needed search. Then I'd spend three days configuring it, mapping fields, tuning analyzers, and wondering why it was consuming 2GB of RAM to index 50,000 documents.
Meilisearch does the same job in a fraction of the complexity and resource usage. For most Laravel applications, it's the right choice.
Why Not Elasticsearch?
Elasticsearch is a distributed, horizontally-scalable search engine designed for petabyte-scale data. If you're indexing billions of documents across a cluster of servers, it's brilliant. If you're searching 100,000 events on a single VPS, it's like hiring an articulated lorry to move a bookshelf.
The operational overhead alone is significant: JVM tuning, cluster health management, index lifecycle policies, shard allocation. And it eats RAM. A minimum viable Elasticsearch node wants 4GB of heap space.
Why Not Algolia?
Algolia is hosted search-as-a-service and it's genuinely excellent. The search quality is brilliant, the dashboard is nice, and the documentation is superb.
The problem is cost. Algolia charges per search operation. For a site with moderate traffic, you're looking at $35-100+/month. Meilisearch running on the same VPS as your app costs nothing beyond the resources it uses (which are minimal).
Why Meilisearch
- Open source and self-hosted. No per-search charges.
- Typo-tolerant out of the box. Users misspelling "restaruant" still find restaurants.
- Fast. Sub-50ms search responses on datasets under a million documents.
- Low resource usage. Runs comfortably on 512MB RAM for typical datasets.
- Laravel Scout driver. Official support, minimal configuration.
Installation
If you're using Coolify (see my article on deploying with Coolify), add Meilisearch as a service in your project. Otherwise, the simplest approach:
# Docker (recommended for production)
docker run -d \
--name meilisearch \
-p 7700:7700 \
-e MEILI_MASTER_KEY='your-secret-master-key' \
-v meilisearch_data:/meili_data \
getmeili/meilisearch:v1.12
For local development on macOS:
brew install meilisearch
meilisearch --master-key='your-dev-key'
Laravel Scout Setup
Install Scout and the Meilisearch driver:
composer require laravel/scout meilisearch/meilisearch-php
Configure in .env:
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-secret-master-key
Add the Searchable trait to your model:
use Laravel\Scout\Searchable;
class Event extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'venue_name' => $this->venue?->name,
'category' => $this->category?->name,
'tags' => $this->tags->pluck('name')->all(),
'city' => $this->venue?->city,
'date' => $this->starts_at?->timestamp,
'price_from' => $this->price_from,
'is_featured' => $this->is_featured,
'created_at' => $this->created_at->timestamp,
];
}
public function searchableAs(): string
{
return 'events';
}
}
The toSearchableArray method defines what data gets indexed. Notice I'm flattening relationships — venue_name instead of a nested venue object. Meilisearch handles flat documents much better than deeply nested ones.
Import Existing Data
php artisan scout:import "App\Models\Event"
This indexes all existing records. New records are indexed automatically when created or updated (Scout hooks into Eloquent events).
Configuring Meilisearch Settings
This is where most people stop, and where the real power is. Meilisearch's default settings are decent but tuning them makes a significant difference.
Create an Artisan command to configure your index:
class ConfigureMeilisearchIndexes extends Command
{
protected $signature = 'search:configure';
public function handle(): int
{
$client = app(\Meilisearch\Client::class);
$index = $client->index('events');
// Searchable attributes — order matters for relevance
$index->updateSearchableAttributes([
'name', // Most important
'venue_name', // Second
'description', // Third
'category', // Fourth
'tags', // Fifth
'city', // Sixth
]);
// Filterable attributes — for faceted search
$index->updateFilterableAttributes([
'category',
'city',
'date',
'price_from',
'is_featured',
'tags',
]);
// Sortable attributes
$index->updateSortableAttributes([
'date',
'price_from',
'created_at',
]);
// Ranking rules — order determines priority
$index->updateRankingRules([
'words', // Documents with more matching words rank higher
'typo', // Fewer typos ranks higher
'proximity', // Matching words closer together rank higher
'attribute', // Matches in earlier searchable attributes rank higher
'sort', // User-requested sort
'exactness', // Exact matches rank higher
'date:desc', // More recent events rank higher
]);
// Typo tolerance settings
$index->updateTypoTolerance([
'minWordSizeForTypos' => [
'oneTypo' => 4, // Allow 1 typo in words 4+ chars
'twoTypos' => 8, // Allow 2 typos in words 8+ chars
],
]);
$this->info('Meilisearch indexes configured.');
return self::SUCCESS;
}
}
Run this once after deployment and whenever you change the configuration.
Searchable Attribute Order
The order of searchableAttributes matters. Meilisearch uses it for the attribute ranking rule — a match in the name field (position 1) is considered more relevant than a match in description (position 3).
For an event search:
name— if the search matches the event name, it's almost certainly what they wantvenue_name— searching for a venue name to find events theredescription— broader content matchcategoryandtags— structural matches
Filterable Attributes and Faceted Search
Filterable attributes allow you to combine full-text search with structured filters:
// Search for "jazz" events in London under £30
$results = Event::search('jazz')
->where('city', 'London')
->where('price_from', '<=', 30)
->get();
Scout translates these into Meilisearch filter expressions. The filter runs on the search engine, not in PHP, so it's fast.
For a faceted search UI (showing category counts alongside results):
// In your controller
$searchResult = Event::search($request->input('q', ''))
->options([
'facets' => ['category', 'city'],
'filter' => $this->buildFilters($request),
])
->raw();
// $searchResult['facetDistribution'] contains:
// {
// "category": { "Music": 42, "Food": 18, "Art": 7 },
// "city": { "London": 35, "Manchester": 22, "Birmingham": 10 }
// }
This lets you build a search sidebar that shows how many results exist for each filter value — like Amazon's left-hand navigation.
Real Example: Rezzy Event Search
On Rezzy, the search handles events, activities, and venues. The controller:
class SearchController extends Controller
{
public function index(SearchRequest $request): Response
{
$query = $request->validated('q', '');
$filters = [];
if ($category = $request->validated('category')) {
$filters[] = "category = '{$category}'";
}
if ($city = $request->validated('city')) {
$filters[] = "city = '{$city}'";
}
if ($dateFrom = $request->validated('date_from')) {
$filters[] = 'date >= ' . Carbon::parse($dateFrom)->timestamp;
}
if ($dateTo = $request->validated('date_to')) {
$filters[] = 'date <= ' . Carbon::parse($dateTo)->timestamp;
}
$filterString = implode(' AND ', $filters);
$results = Event::search($query)
->options([
'filter' => $filterString ?: null,
'facets' => ['category', 'city'],
'sort' => $this->getSortRules($request),
'limit' => 24,
'offset' => ($request->validated('page', 1) - 1) * 24,
])
->raw();
return Inertia::render('Search/Index', [
'results' => EventData::collection(
Event::findMany(collect($results['hits'])->pluck('id'))
),
'facets' => $results['facetDistribution'] ?? [],
'totalHits' => $results['estimatedTotalHits'] ?? 0,
'query' => $query,
]);
}
private function getSortRules(SearchRequest $request): array
{
return match ($request->validated('sort')) {
'date' => ['date:asc'],
'price_low' => ['price_from:asc'],
'price_high' => ['price_from:desc'],
default => [], // Relevance (default Meilisearch ranking)
};
}
}
The Frontend
function SearchPage({ results, facets, totalHits, query }: Props) {
const [search, setSearch] = useState(query);
return (
<div className="flex gap-8">
<aside className="w-64 shrink-0">
<SearchFilters facets={facets} />
</aside>
<main className="flex-1">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search events, venues, activities..."
/>
<p className="text-sm text-gray-500 mb-4">
{totalHits} results
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map(event => (
<EventCard key={event.uid} event={event} />
))}
</div>
</main>
</div>
);
}
Performance
On Rezzy with ~15,000 events indexed:
- Search response time: 5-15ms from Meilisearch
- Total response time (including Eloquent hydration): 40-80ms
- Index size on disk: ~25MB
- RAM usage: ~150MB
For comparison, Elasticsearch with the same dataset used ~1.5GB of RAM. That's 10x more for identical functionality.
Keeping the Index Fresh
Scout handles index updates automatically for single record changes. For bulk operations, use Scout's bulk methods:
// After a bulk import
Event::query()
->where('status', 'published')
->searchable();
// After soft-deleting a batch
Event::query()
->onlyTrashed()
->where('deleted_at', '>', now()->subHour())
->unsearchable();
For data that changes frequently (like availability or booking counts), consider a scheduled reindex:
Schedule::command('scout:import', ['App\\Models\\Event', '--chunk=500'])
->dailyAt('03:00');
When Meilisearch Isn't Enough
Meilisearch is single-node. It doesn't do distributed search, cross-index joins, or complex aggregations. If you need:
- Geo-distance sorting: Meilisearch supports basic geo search, but PostGIS is better for complex spatial queries
- Analytics/aggregations: use your database or a proper analytics tool
- More than ~10 million documents: Elasticsearch starts making more sense
- Real-time indexing with sub-second latency at scale: Elasticsearch or Algolia
For 95% of Laravel applications, those caveats don't apply. Meilisearch handles the search, your database handles the complex queries, and everyone's happy.
The Setup Checklist
- Install Meilisearch (Docker or native)
composer require laravel/scout meilisearch/meilisearch-php- Configure
.envwith host and master key - Add
Searchabletrait andtoSearchableArray()to models - Create a command to configure index settings
- Import existing data with
scout:import - Build your search controller with filters
- Done
Twenty minutes to basic search. An afternoon to get faceted search, custom ranking, and a proper UI. That's the Meilisearch pitch, and in my experience, it delivers.
I write about Laravel, AI tooling, and building software. More at stuartmason.co.uk.
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.