Meilisearch: Site Search in 200 Lines That Beats ES for Most Apps
I spent years reaching for Elasticsearch out of habit. Meilisearch does 90% of what I actually need at a fraction of the operational cost.
I kept reaching for Elasticsearch the way a surgeon reaches for a scalpel — out of habit, not necessity. Then a client needed product search on a mid-sized e-commerce catalog, I stood up Meilisearch on a $12 VPS instead, and it was running in an afternoon. That was two years ago. I've used it on four projects since and haven't touched ES once.
What Problem Meilisearch Actually Solves
Elasticsearch is a distributed search cluster. It's spectacular when you have billions of documents, need complex aggregations, or already have an Elastic stack for logging. For most web apps, it's like driving a semi truck to the grocery store. You spend your first week on JVM tuning, index mapping strategies, and convincing yourself the minimum_should_match syntax is readable. Then you need a DevOps person to keep it running.
Meilisearch is a single Rust binary. You drop it on a server, point your app at it over HTTP, and you get:
- Typo-tolerant full-text search out of the box
- Faceted filtering and sorting
- Geosearch
- Synonym and stop-word configuration
- Sub-50ms responses on millions of documents
- A REST API so clean it feels like it was designed by someone who had used bad APIs before
It's self-hosted (or available as a cloud service), open-source under an MIT-compatible license, and the entire operational footprint fits on a $6/month droplet for most apps I build.
What it doesn't do: distributed sharding across nodes, complex aggregations like Kibana-style analytics, or full SQL-style joins at query time. If you need those things, use Elasticsearch. If you're building site search, product search, or a document finder for a SaaS app, Meilisearch will make you look fast.
A Working Laravel Integration
Laravel Scout has a Meilisearch driver, which is the 80% path. But I'll show you the direct HTTP client approach too, because sometimes Scout's abstraction gets in the way and you want to understand what's actually happening.
First, install the PHP SDK:
composer require meilisearch/meilisearch-php
Then wire up a service provider or just use a singleton in your AppServiceProvider:
// AppServiceProvider.php
use Meilisearch\Client;
$this->app->singleton(Client::class, function () {
return new Client(
config('services.meilisearch.url'), // e.g. http://localhost:7700
config('services.meilisearch.key') // your master key
);
});
Now let's index a product catalog. I'll keep it realistic — this is roughly the shape of a catalog I built for a Seattle-area retailer last year:
// IndexProductsCommand.php
use Meilisearch\Client;
class IndexProductsCommand extends Command
{
protected $signature = 'search:index-products';
public function handle(Client $client): void
{
$index = $client->index('products');
// Configure filterable and sortable attributes first
$index->updateFilterableAttributes([
'category_id',
'brand',
'in_stock',
'price',
'tags',
]);
$index->updateSortableAttributes([
'price',
'created_at',
'name',
]);
$index->updateSearchableAttributes([
'name',
'description',
'brand',
'sku',
'tags',
]);
// Batch index in chunks — Meilisearch handles up to 50MB per request
Product::with('category', 'tags')
->cursor()
->chunk(500)
->each(function ($chunk) use ($index) {
$documents = $chunk->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'description' => $p->description,
'brand' => $p->brand,
'sku' => $p->sku,
'category_id' => $p->category_id,
'price' => (float) $p->price,
'in_stock' => $p->qty_on_hand > 0,
'tags' => $p->tags->pluck('name')->toArray(),
'created_at' => $p->created_at->timestamp,
])->values()->toArray();
$task = $index->addDocuments($documents);
$this->line("Queued task: {$task['taskUid']}");
});
}
}
Note the updateFilterableAttributes call before indexing. That's the step people skip and then wonder why filters return nothing. Meilisearch has to build separate internal structures for filterable fields, so you declare them up front. Add a field later and you'll need to re-index — same discipline ES requires, just more explicit.
Now the search endpoint:
// ProductSearchController.php
use Meilisearch\Client;
class ProductSearchController extends Controller
{
public function __invoke(Request $request, Client $client): JsonResponse
{
$validated = $request->validate([
'q' => 'nullable|string|max:200',
'category' => 'nullable|integer',
'brand' => 'nullable|string',
'in_stock' => 'nullable|boolean',
'min_price' => 'nullable|numeric',
'max_price' => 'nullable|numeric',
'sort' => 'nullable|in:price_asc,price_desc,newest',
'page' => 'nullable|integer|min:1',
]);
$filters = [];
if ($validated['category'] ?? null) {
$filters[] = "category_id = {$validated['category']}";
}
if ($validated['brand'] ?? null) {
$brand = addslashes($validated['brand']);
$filters[] = "brand = '{$brand}'";
}
if ($validated['in_stock'] ?? null) {
$filters[] = 'in_stock = true';
}
if (($validated['min_price'] ?? null) !== null) {
$filters[] = "price >= {$validated['min_price']}";
}
if (($validated['max_price'] ?? null) !== null) {
$filters[] = "price <= {$validated['max_price']}";
}
$sortMap = [
'price_asc' => 'price:asc',
'price_desc' => 'price:desc',
'newest' => 'created_at:desc',
];
$params = [
'hitsPerPage' => 24,
'page' => $validated['page'] ?? 1,
'filter' => implode(' AND ', $filters) ?: null,
'sort' => isset($validated['sort'])
? [$sortMap[$validated['sort']]]
: null,
'attributesToHighlight' => ['name', 'description'],
'highlightPreTag' => '<mark>',
'highlightPostTag' => '</mark>',
];
// Strip null values — Meilisearch ignores them but it's cleaner
$params = array_filter($params, fn($v) => $v !== null);
$results = $client->index('products')->search(
$validated['q'] ?? '',
$params
);
return response()->json([
'hits' => $results->getHits(),
'total' => $results->getTotalHits(),
'processing_time' => $results->getProcessingTimeMs(),
'pages' => $results->getTotalPages(),
]);
}
}
That's roughly 120 lines covering indexing and search with filters, sorting, pagination, and highlighted snippets. The processing_time field I expose to the frontend — watching it hover around 2–8ms for a 40,000-document index never gets old.
The Gotchas That Will Bite You
Task asynchrony. Every write operation — addDocuments, updateSettings, deleteIndex — returns a task UID, not a confirmation. Meilisearch queues and processes them asynchronously. In tests or one-shot scripts, you'll call addDocuments, immediately call search, and get zero results. Add a waitForTask($taskUid) call or just sleep briefly in development. I've tripped on this in every project until I wired up a helper.
Settings changes trigger re-indexing. Adding a field to filterableAttributes after you've already indexed 200,000 documents will kick off a full re-index. On a small VPS this can take a few minutes. Plan your schema before you bulk-import. I now always run a search:configure-index command before search:index-* on fresh deployments.
The filter syntax is its own thing. It's not SQL, it's not Elasticsearch's JSON DSL. It looks like price >= 10 AND (brand = 'Acme' OR brand = 'Ajax'). You'll build filter strings manually like I showed above. Sanitize user input before interpolating it in — the SDK doesn't parameterize filter strings for you.
Scout's MeiliSearch driver gets out of sync. If you use Laravel Scout with the Meilisearch driver and you're running model observers to auto-update the index, watch for jobs failing silently in high-write scenarios. I had a client's product index drift noticeably from their database after a botched import. I now run a nightly search:full-reindex job as a safety net on any project where the index matters.
Meilisearch Cloud vs. self-hosted. Their hosted cloud product is convenient, but pricier than running it yourself. For most of my clients I self-host on a dedicated droplet. The one gotcha: if you let it share memory with your PHP app on a single VPS, you'll need to tune the --max-indexing-memory flag or you'll hit swap during large re-indexes. I give Meilisearch its own $12 droplet once a project's catalog hits 100k+ documents.
When I'd Reach for Meilisearch
Anytime a client needs user-facing search on structured content: product catalogs, blog/documentation search, directory listings, job boards, real estate listings. I put it on a print-management SaaS I maintain — users search across thousands of order templates with typo tolerance, and the client literally said "it feels like Google" the first time they used it.
I'd also reach for it for internal tools: admin dashboards where staff search customers, orders, or records. Sub-10ms response times and typo tolerance mean less support overhead.
When I wouldn't use it: log aggregation and analytics queries — that's Elasticsearch's home turf. Complex relational queries that really want SQL. Document counts in the hundreds — just use a LIKE query or Laravel Scout with the database driver. And anywhere you need strict data governance with field-level access control; Meilisearch's API key system is functional but not fine-grained enough for multi-tenant SaaS without extra architecture around it.
Bottom Line
Meilisearch doesn't try to be everything, and that's why it's good. I've built better search UX with it in a day than I used to deliver with Elasticsearch in a week. If you're still defaulting to ES for a 50,000-document catalog, do yourself a favor and try this first.
Need help shipping something like this? Get in touch.