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. This leads to frustrated users who can't find products you actually have.
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 and testing.
PHP Client
To interact with Elasticsearch from PHP, you'll need the official client library. Install it via Composer.
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 rather than creating a new one for each request.
// 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. Your entire application will share one client instance.
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 and multi-field mappings for flexible querying.
$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." This layered approach lets you use the same field for different query types.
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 where you need changes reflected immediately.
$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. You can call this same code for both creates and updates.
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. Here's a pattern that processes large datasets efficiently without exhausting memory.
$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 without requiring manual intervention.
// 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 without affecting the user's experience.
Basic Search
Simple Query
The multi_match query searches across multiple fields simultaneously. This is the workhorse query for most search implementations, handling the common case where users type something and expect relevant results.
$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. Products matching the search term in their title should usually rank higher than those matching only in the description.
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 for maximum flexibility.
$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. Use filters for any criteria that should narrow results without affecting ranking.
Pagination
Elasticsearch uses from/size pagination rather than page numbers. You'll need to calculate the offset based on the current page. This approach is familiar if you've used SQL's LIMIT and OFFSET.
$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 because Elasticsearch must process all preceding documents. 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 to power filter interfaces.
$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. Users get immediate feedback about how many results they'll see if they click a filter.
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 that handles millions of suggestions.
// 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. The completion suggester is extremely fast but less flexible than full search queries.
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 because you get full search results rather than just suggestions.
// 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 with ranking and filtering.
Relevance Tuning
Boosting Fields
Field boosting adjusts how much each field contributes to the overall relevance score. Matches in more important fields rank higher, helping users find the most relevant results first.
'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, blending text relevance with business logic.
'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 and what behavior you want to encourage.
Highlighting
Highlighting shows users why a result matched by wrapping matching terms in markup. This provides immediate visual feedback about relevance and helps users scan results quickly.
$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, which is particularly useful for long description fields.
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 without diving into the Elasticsearch query DSL.
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. Scout handles the complexity of keeping your index in sync.
// 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, but for many applications the simplicity is worth it.
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, making subsequent queries with the same filter much faster.
// 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, especially for documents with large text fields.
'_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 when you need to make mapping changes or rebuild from scratch.
// 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. Your application continues searching through the alias while you build and test the new index.
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.