Search is often the fastest way for users to find what they need. While simple database LIKE queries work for small datasets, they fall apart as data grows. Elasticsearch provides powerful full-text search, instant results, and sophisticated relevance ranking. This guide covers building search features from basic to advanced.
Why Elasticsearch?
Database Search Limitations
Traditional SQL pattern matching quickly becomes a bottleneck as your dataset grows. Consider a typical product search query that relies on LIKE clauses.
-- Slow on large tables
SELECT * FROM products WHERE title LIKE '%wireless headphones%'
-- Doesn't understand synonyms, typos, or relevance
-- "wireless headphones" won't match "bluetooth earbuds"
This approach forces full table scans and lacks any understanding of language semantics. Users searching for "laptop" won't find results containing "notebook," even though they mean the same thing.
Elasticsearch Advantages
- Full-text search: Understands language, synonyms, stemming
- Speed: Inverted index enables millisecond searches
- Relevance: Sophisticated ranking algorithms
- Scalability: Distributed by design
- Rich queries: Facets, suggestions, aggregations
Getting Started
Installation
The fastest way to get Elasticsearch running locally is through Docker. This single command spins up a development instance without security overhead.
# Docker
docker run -d --name elasticsearch \
-p 9200:9200 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
elasticsearch:8.12.0
For production deployments, you'll want to enable security and configure clustering, but this setup works perfectly for development.
PHP Client
To interact with Elasticsearch from PHP, you'll need the official client library.
composer require elasticsearch/elasticsearch
Once installed, configure the client as a singleton in your service container. This ensures you reuse the same connection throughout your application's lifecycle.
// config/services.php
'elasticsearch' => [
'hosts' => [env('ELASTICSEARCH_HOST', 'localhost:9200')],
],
// Service provider
use Elastic\Elasticsearch\ClientBuilder;
$this->app->singleton(Client::class, function () {
return ClientBuilder::create()
->setHosts(config('services.elasticsearch.hosts'))
->build();
});
The singleton pattern prevents opening multiple connections, which can exhaust connection limits under heavy load.
Index Design
Creating an Index
Before you can search documents, you need to create an index with a well-designed mapping. This is where you define how Elasticsearch should analyze and store your data. The following example creates a products index with custom analyzers for synonym support.
$client->indices()->create([
'index' => 'products',
'body' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 0,
'analysis' => [
'analyzer' => [
'product_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'snowball', 'synonym_filter'],
],
],
'filter' => [
'synonym_filter' => [
'type' => 'synonym',
'synonyms' => [
'laptop,notebook,portable computer',
'phone,mobile,smartphone',
],
],
],
],
],
'mappings' => [
'properties' => [
'title' => [
'type' => 'text',
'analyzer' => 'product_analyzer',
'fields' => [
'keyword' => ['type' => 'keyword'],
'suggest' => ['type' => 'completion'],
],
],
'description' => ['type' => 'text'],
'price' => ['type' => 'float'],
'category' => ['type' => 'keyword'],
'tags' => ['type' => 'keyword'],
'in_stock' => ['type' => 'boolean'],
'created_at' => ['type' => 'date'],
],
],
],
]);
Notice the multi-field mapping on title. The main field uses text analysis for full-text search, while keyword allows exact matching and suggest enables autocomplete. The snowball filter handles stemming, so "running" matches "runs" and "ran."
Indexing Documents
Single Document
When creating or updating individual products, you can index them one at a time. This approach works well for real-time updates triggered by model events.
$client->index([
'index' => 'products',
'id' => $product->id,
'body' => [
'title' => $product->title,
'description' => $product->description,
'price' => $product->price,
'category' => $product->category->name,
'tags' => $product->tags->pluck('name')->toArray(),
'in_stock' => $product->in_stock,
'created_at' => $product->created_at->toIso8601String(),
],
]);
Using the database ID as the Elasticsearch document ID makes it easy to update or delete documents later.
Bulk Indexing
When you need to index thousands of documents, such as during initial setup or a full reindex, individual requests become painfully slow. Bulk indexing sends multiple documents in a single HTTP request, dramatically improving throughput.
$products = Product::with(['category', 'tags'])->cursor();
$params = ['body' => []];
$count = 0;
foreach ($products as $product) {
$params['body'][] = [
'index' => [
'_index' => 'products',
'_id' => $product->id,
],
];
$params['body'][] = [
'title' => $product->title,
'description' => $product->description,
'price' => $product->price,
'category' => $product->category->name,
'tags' => $product->tags->pluck('name')->toArray(),
'in_stock' => $product->in_stock,
'created_at' => $product->created_at->toIso8601String(),
];
$count++;
// Bulk send every 1000 documents
if ($count % 1000 === 0) {
$client->bulk($params);
$params = ['body' => []];
}
}
// Send remaining
if (!empty($params['body'])) {
$client->bulk($params);
}
The cursor method streams results from the database to avoid loading everything into memory. Batching at 1000 documents balances memory usage against HTTP overhead. Adjust this number based on your document size and available memory.
Keep Index in Sync
To maintain consistency between your database and search index, hook into Eloquent's model events. This ensures every create, update, or delete automatically reflects in Elasticsearch.
// In Product model
protected static function booted()
{
static::saved(function (Product $product) {
dispatch(new IndexProduct($product));
});
static::deleted(function (Product $product) {
dispatch(new RemoveProductFromIndex($product->id));
});
}
Dispatching to a queue prevents search indexing from slowing down your main application. If Elasticsearch is temporarily unavailable, the job can retry later.
Basic Search
Simple Query
The multi_match query searches across multiple fields simultaneously. This is the workhorse query for most search implementations.
$response = $client->search([
'index' => 'products',
'body' => [
'query' => [
'multi_match' => [
'query' => $searchTerm,
'fields' => ['title^3', 'description', 'tags'],
],
],
],
]);
$hits = $response['hits']['hits'];
$total = $response['hits']['total']['value'];
$results = collect($hits)->map(fn($hit) => [
'id' => $hit['_id'],
'score' => $hit['_score'],
...$hit['_source'],
]);
The ^3 syntax boosts the title field, making matches there three times more important than description matches. Experiment with these weights to find what feels right for your users.
With Filters
When users refine their search with facets like category or price range, you'll combine full-text search with exact filters. The bool query lets you mix scoring queries with non-scoring filters.
$body = [
'query' => [
'bool' => [
'must' => [
'multi_match' => [
'query' => $searchTerm,
'fields' => ['title^3', 'description'],
],
],
'filter' => [
['term' => ['in_stock' => true]],
['range' => ['price' => ['gte' => 10, 'lte' => 100]]],
],
],
],
];
if ($category) {
$body['query']['bool']['filter'][] = ['term' => ['category' => $category]];
}
Filters don't affect relevance scoring and are cached by Elasticsearch, making them much faster than equivalent must clauses.
Pagination
Elasticsearch uses from/size pagination rather than page numbers. You'll need to calculate the offset based on the current page.
$page = $request->get('page', 1);
$perPage = 20;
$response = $client->search([
'index' => 'products',
'body' => [
'from' => ($page - 1) * $perPage,
'size' => $perPage,
'query' => [...],
'sort' => [
'_score',
['created_at' => 'desc'],
],
],
]);
Be aware that deep pagination (high page numbers) becomes expensive. For large result sets, consider search_after pagination or limiting how far users can navigate.
Aggregations (Facets)
Category Facets
Aggregations let you build faceted navigation, showing users how many results exist in each category or price range. You can run aggregations alongside your search query.
$response = $client->search([
'index' => 'products',
'body' => [
'size' => 20,
'query' => [...],
'aggs' => [
'categories' => [
'terms' => ['field' => 'category'],
],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 50],
['from' => 50, 'to' => 100],
['from' => 100, 'to' => 200],
['from' => 200],
],
],
],
'avg_price' => [
'avg' => ['field' => 'price'],
],
],
],
]);
$facets = $response['aggregations'];
// $facets['categories']['buckets'] = [['key' => 'Electronics', 'doc_count' => 42], ...]
The buckets array contains each category with its document count, which you can display as clickable filters in your UI.
Autocomplete
Completion Suggester
For instant search suggestions as users type, the completion suggester provides optimized prefix matching. It's specifically designed for speed and is stored in an in-memory data structure.
// Search as you type
$response = $client->search([
'index' => 'products',
'body' => [
'suggest' => [
'product-suggest' => [
'prefix' => $prefix,
'completion' => [
'field' => 'title.suggest',
'size' => 5,
'skip_duplicates' => true,
],
],
],
],
]);
$suggestions = $response['suggest']['product-suggest'][0]['options'];
This requires the completion field type in your mapping, which we added earlier to the title field.
Search-As-You-Type Field
An alternative approach uses the search_as_you_type field type, which automatically creates edge n-grams for partial matching. This offers more flexibility than the completion suggester.
// In mapping
'title' => [
'type' => 'search_as_you_type',
],
// In query
'query' => [
'multi_match' => [
'query' => $searchTerm,
'type' => 'bool_prefix',
'fields' => [
'title',
'title._2gram',
'title._3gram',
],
],
],
The _2gram and _3gram subfields match partial words, so "hea" would match "headphones." Choose based on whether you need simple suggestions or full search results.
Relevance Tuning
Boosting Fields
Field boosting adjusts how much each field contributes to the overall relevance score. Matches in more important fields rank higher.
'multi_match' => [
'query' => $searchTerm,
'fields' => [
'title^3', // 3x more important
'description^1',
'tags^2',
],
],
Function Score
For more sophisticated relevance tuning, function_score lets you factor in additional signals like recency or popularity. This example boosts newer products and those with higher popularity scores.
'query' => [
'function_score' => [
'query' => [...],
'functions' => [
// Boost recent products
[
'exp' => [
'created_at' => [
'origin' => 'now',
'scale' => '30d',
'decay' => 0.5,
],
],
],
// Boost popular products
[
'field_value_factor' => [
'field' => 'popularity',
'factor' => 1.2,
'modifier' => 'sqrt',
'missing' => 1,
],
],
],
'score_mode' => 'multiply',
],
],
The exponential decay function reduces the boost for older documents, while sqrt prevents extremely popular items from dominating results. Tune these parameters based on how your users interact with search results.
Highlighting
Highlighting shows users why a result matched by wrapping matching terms in markup. This provides immediate visual feedback about relevance.
$response = $client->search([
'index' => 'products',
'body' => [
'query' => [...],
'highlight' => [
'fields' => [
'title' => new \stdClass(),
'description' => ['fragment_size' => 150],
],
'pre_tags' => ['<mark>'],
'post_tags' => ['</mark>'],
],
],
]);
// Access highlights
foreach ($response['hits']['hits'] as $hit) {
$highlights = $hit['highlight'] ?? [];
// ['title' => ['<mark>wireless</mark> headphones']]
}
Use stdClass for fields where you want default settings. The fragment_size option controls how much surrounding text to include around matches.
Laravel Scout Integration
If you prefer a more abstracted approach, Laravel Scout provides a clean, Eloquent-like API for search. The Elasticsearch driver makes it easy to get started.
composer require laravel/scout
composer require matchish/laravel-scout-elasticsearch
Configure Scout to use the Elasticsearch driver, then add the Searchable trait to your models.
// config/scout.php
'driver' => env('SCOUT_DRIVER', 'elasticsearch'),
// Product model
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'price' => $this->price,
'category' => $this->category->name,
];
}
}
// Searching
$products = Product::search('wireless headphones')
->where('in_stock', true)
->paginate(20);
Scout handles indexing automatically when models are created, updated, or deleted. The trade-off is less control over advanced Elasticsearch features.
Performance Tips
Use Filters for Non-Scoring
Always use filter context for criteria that shouldn't affect relevance. Filters skip scoring calculations and benefit from caching.
// Filters are cached and don't affect scoring
'bool' => [
'must' => [...], // Affects score
'filter' => [...], // Cached, no scoring
]
Limit Returned Fields
When you don't need all document fields, specify which ones to return. This reduces network transfer and parsing overhead.
'_source' => ['title', 'price', 'slug'],
Use Index Aliases
Aliases provide a layer of indirection that enables zero-downtime reindexing. Point your application at an alias, then swap which index it references.
// Create versioned index
$client->indices()->create(['index' => 'products_v2', ...]);
// Point alias to new index
$client->indices()->updateAliases([
'body' => [
'actions' => [
['remove' => ['index' => 'products_v1', 'alias' => 'products']],
['add' => ['index' => 'products_v2', 'alias' => 'products']],
],
],
]);
This atomic operation means users never experience downtime during mapping changes or full reindexes.
Conclusion
Elasticsearch transforms search from a basic feature into a powerful tool for content discovery. Start with simple multi-match queries, add filters and facets for refinement, and tune relevance based on user behavior. The investment in proper search infrastructure pays off in user satisfaction and engagement.