- π‘ MySQL LIKE queries don't scale past 100k records (learned this the hard way)
- β Elasticsearch + Laravel Scout = search in under 50ms, even with millions of records
- π Full-text search, typo tolerance, and relevance scoring out of the box
- π Complete implementation guide from zero to production
- β οΈ Avoid the 3 gotchas that cost me 2 days of debugging
βββββββββββββββββββββββββββββ
The Nightmare Before Elasticsearch
Picture this: Your Laravel app has 500k products. User types "wireless headphones" into search.
8.3 seconds later, results appear.
User has already left. Your bounce rate is through the roof.
// What we all start with (and regret)
Product::where('name', 'LIKE', '%wireless%')
->where('description', 'LIKE', '%headphones%')
->get();
// Query time: 8,300ms π
// Database CPU: 98%
// Your sanity: Gone
You add indexes. It helps... a little. Still 3-4 seconds for complex searches.
Then your PM asks: "Can we add typo tolerance? And sort by relevance?"
That's when you realize you need Elasticsearch.
βββββββββββββββββββββββββββββ
π‘ Why Elasticsearch + Laravel = Magic
Elasticsearch isn't just "faster search." It's a fundamentally different approach:
MySQL: Scans rows, checks conditions, prays for good indexes
Elasticsearch: Inverted index, tokenization, relevance scoring built-in
Think of it like this:
- MySQL = Looking through every book in a library
- Elasticsearch = Using the library's card catalog system
What You Get Out of the Box
β
Typo tolerance: "wireles headpones" still works
β
Relevance scoring: Best matches first, automatically
β
Full-text search: Understands "wireless Bluetooth headphones" as concepts
β
Faceted search: Filters, aggregations, analytics
β
Blazing speed: Sub-100ms for millions of records
βββββββββββββββββββββββββββββ
π Setup: From Zero to Searching in 15 Minutes
Step 1: Install Elasticsearch
Docker (Recommended):
docker run -d \
--name elasticsearch \
-p 9200:9200 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
elasticsearch:8.11.0
# Verify it's running
curl http://localhost:9200
Or use Elastic Cloud (easier for production, $16/mo starter tier)
βββββββββββββββββββββββββββββ
Step 2: Install Laravel Scout + Elasticsearch Driver
composer require laravel/scout
composer require matchish/laravel-scout-elasticsearch
# Publish config
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
php artisan vendor:publish --provider="Matchish\ScoutElasticSearch\ScoutElasticSearchServiceProvider"
Configure .env:
SCOUT_DRIVER=elasticsearch
SCOUT_PREFIX=${APP_NAME}_
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
SCOUT_QUEUE=true # π₯ Index asynchronously
βββββββββββββββββββββββββββββ
Step 3: Make Your Model Searchable
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
/**
* Get the indexable data array for the model.
*/
public function toSearchableArray()
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'category' => $this->category->name,
'price' => $this->price,
'brand' => $this->brand,
'tags' => $this->tags->pluck('name')->toArray(),
// π‘ Only index what you'll search/filter by
];
}
/**
* Modify the query used to retrieve models when making all searchable.
*/
protected function makeAllSearchableUsing($query)
{
return $query->with(['category', 'tags']); // π₯ Prevent N+1
}
}
βββββββββββββββββββββββββββββ
Step 4: Index Your Existing Data
# Import all existing records
php artisan scout:import "App\Models\Product"
# For large datasets (100k+ records), use chunking
php artisan scout:import "App\Models\Product" --chunk=1000
What's happening behind the scenes:
- Laravel loads your models in chunks
- Calls
toSearchableArray()on each - Bulk indexes to Elasticsearch
- Queues it if
SCOUT_QUEUE=true
βββββββββββββββββββββββββββββ
π― Search Implementation: The Good Stuff
Basic Search (The 80% Use Case)
use App\Models\Product;
// Simple search
$results = Product::search('wireless headphones')->get();
// With pagination
$results = Product::search('wireless headphones')
->paginate(20);
// That's it. Really. 47ms average response time.
Advanced Search with Filters
$results = Product::search('laptop')
->where('price', '>=', 500)
->where('price', '<=', 2000)
->where('brand', 'Apple')
->orderBy('price', 'asc')
->paginate(20);
// Query time: 52ms
// MySQL equivalent: 4,200ms π
βββββββββββββββββββββββββββββ
Custom Scoring & Boosting
use Matchish\ScoutElasticSearch\ElasticSearch\Query\Builder;
$results = Product::search('headphones')
->query(function (Builder $builder) {
// Boost exact name matches
$builder->should([
'match' => [
'name' => [
'query' => 'headphones',
'boost' => 3 // 3x relevance score
]
]
]);
// Boost premium brands
$builder->should([
'term' => [
'brand' => [
'value' => 'Sony',
'boost' => 2
]
]
]);
return $builder;
})
->get();
βββββββββββββββββββββββββββββ
Typo Tolerance (Fuzzy Search)
$results = Product::search('wireles hedphones') // Typos!
->query(function (Builder $builder) {
$builder->should([
'match' => [
'name' => [
'query' => 'wireles hedphones',
'fuzziness' => 'AUTO' // π Magic setting
]
]
]);
return $builder;
})
->get();
// Still finds "wireless headphones" π
Fuzziness levels:
-
AUTO: Smart auto-adjustment (recommended) -
1: Allows 1 character difference -
2: Allows 2 character differences
βββββββββββββββββββββββββββββ
πͺ Real-World Search Features
Autocomplete/Search Suggestions
// In your controller
public function suggestions(Request $request)
{
$query = $request->input('q');
$suggestions = Product::search($query)
->take(5) // Top 5 suggestions
->get()
->pluck('name');
return response()->json($suggestions);
}
Frontend (Alpine.js example):
<div x-data="searchBox()">
<input
type="text"
x-model="query"
@input.debounce.300ms="fetchSuggestions"
placeholder="Search products..."
>
<ul x-show="suggestions.length">
<template x-for="suggestion in suggestions">
<li x-text="suggestion"></li>
</template>
</ul>
</div>
<script>
function searchBox() {
return {
query: '',
suggestions: [],
async fetchSuggestions() {
if (this.query.length < 2) return;
const response = await fetch(`/api/suggestions?q=${this.query}`);
this.suggestions = await response.json();
}
}
}
</script>
βββββββββββββββββββββββββββββ
Faceted Search (Filters with Counts)
use Matchish\ScoutElasticSearch\ElasticSearch\Query\Builder;
$results = Product::search('laptop')
->query(function (Builder $builder) {
// Add aggregations for facets
$builder->aggregation('brands', [
'terms' => ['field' => 'brand.keyword', 'size' => 10]
]);
$builder->aggregation('price_ranges', [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 500],
['from' => 500, 'to' => 1000],
['from' => 1000, 'to' => 2000],
['from' => 2000]
]
]
]);
return $builder;
})
->raw();
// Access aggregations
$brands = $results['aggregations']['brands']['buckets'];
$priceRanges = $results['aggregations']['price_ranges']['buckets'];
/*
Output:
brands: [
{ key: "Apple", doc_count: 42 },
{ key: "Dell", doc_count: 38 },
{ key: "HP", doc_count: 27 }
]
*/
βββββββββββββββββββββββββββββ
π₯ Production-Ready Tips
1. Custom Index Configuration
Create config/scout-elasticsearch.php:
return [
'indices' => [
'mappings' => [
'products' => [
'properties' => [
'name' => [
'type' => 'text',
'analyzer' => 'standard',
'fields' => [
'keyword' => ['type' => 'keyword'] // For exact match
]
],
'description' => [
'type' => 'text',
'analyzer' => 'english' // Better stemming
],
'price' => ['type' => 'float'],
'brand' => [
'type' => 'text',
'fields' => [
'keyword' => ['type' => 'keyword'] // For filtering
]
],
'created_at' => ['type' => 'date']
]
]
]
]
];
βββββββββββββββββββββββββββββ
2. Handle Updates Automatically
// In your model, Scout handles this automatically:
$product = Product::find(1);
$product->name = 'Updated Name';
$product->save(); // π Automatically re-indexes
$product->delete(); // π Automatically removes from index
For bulk updates:
// Temporarily disable indexing
Product::withoutSyncingToSearch(function () {
Product::where('category_id', 5)->update(['on_sale' => true]);
});
// Then re-index
Product::where('category_id', 5)->searchable();
βββββββββββββββββββββββββββββ
3. Queue Everything (Seriously)
// .env
SCOUT_QUEUE=true
QUEUE_CONNECTION=redis // Or database, SQS, etc.
// This makes indexing async:
$product->save(); // Returns immediately
// Indexes in background job
Why this matters:
- User doesn't wait for Elasticsearch
- Handles Elasticsearch downtime gracefully
- Scales to millions of updates/day
βββββββββββββββββββββββββββββ
β οΈ 3 Gotchas That Will Bite You
Gotcha #1: Keyword vs Text Fields
// β WRONG: Won't work for exact filtering
Product::search('laptop')->where('brand', 'Apple')->get();
// Problem: 'brand' is analyzed text, not keyword
Solution:
// In index config, add .keyword field
'brand' => [
'type' => 'text',
'fields' => [
'keyword' => ['type' => 'keyword']
]
]
// Then filter using .keyword
Product::search('laptop')
->whereIn('brand.keyword', ['Apple', 'Dell'])
->get();
βββββββββββββββββββββββββββββ
Gotcha #2: Not Handling Elasticsearch Downtime
// β WRONG: App crashes if Elasticsearch is down
$results = Product::search($query)->get();
Solution:
// β
RIGHT: Fallback to database
try {
$results = Product::search($query)->paginate(20);
} catch (\Exception $e) {
Log::error('Elasticsearch down: ' . $e->getMessage());
// Fallback to MySQL
$results = Product::where('name', 'LIKE', "%{$query}%")
->orWhere('description', 'LIKE', "%{$query}%")
->paginate(20);
}
βββββββββββββββββββββββββββββ
Gotcha #3: Forgetting to Re-index After Config Changes
// Changed index mappings? Data won't update automatically!
// β WRONG: Assuming it updates
// Edit config, expect magic
// β
RIGHT: Delete and re-import
php artisan scout:flush "App\Models\Product"
php artisan scout:import "App\Models\Product"
π‘ Pro tip: Use index aliases in production to avoid downtime during re-indexing
βββββββββββββββββββββββββββββ
π Performance Comparison: The Numbers
Test setup:
- 500,000 products
- Search: "wireless bluetooth headphones under $100"
- Filters: brand, price range, rating
- Laravel 11, MySQL 8.0, Elasticsearch 8.11
| Method | Avg Response Time | 95th Percentile | Database CPU |
|---|---|---|---|
| MySQL LIKE | 8,300ms | 12,400ms | 94% |
| MySQL Full-Text | 2,100ms | 3,800ms | 78% |
| Elasticsearch | 47ms | 89ms | 12% |
Real impact:
- 176x faster than MySQL LIKE
- 44x faster than MySQL full-text
- CPU usage dropped from 94% to 12%
- Can handle 1000+ concurrent searches
βββββββββββββββββββββββββββββ
π Bonus: Search Analytics
Track what users search for:
use Illuminate\Support\Facades\DB;
class SearchController extends Controller
{
public function search(Request $request)
{
$query = $request->input('q');
// Log search query
DB::table('search_logs')->insert([
'query' => $query,
'results_count' => 0,
'user_id' => auth()->id(),
'created_at' => now()
]);
$results = Product::search($query)->get();
// Update results count
DB::table('search_logs')
->where('query', $query)
->latest()
->limit(1)
->update(['results_count' => $results->count()]);
return view('search.results', compact('results', 'query'));
}
}
Use this data to:
- Find searches with zero results β add missing products
- Identify trending searches β stock popular items
- Improve synonyms β map "phone" to "smartphone"
βββββββββββββββββββββββββββββ
π Your Action Plan
Week 1: Basic Setup
- Spin up Elasticsearch (Docker or Elastic Cloud)
- Install Scout + Elasticsearch driver
- Make your main model searchable
- Import existing data
Week 2: Enhance
- Add filters and sorting
- Implement autocomplete
- Configure custom analyzers
- Add search analytics
Week 3: Optimize
- Fine-tune relevance scoring
- Set up queued indexing
- Add fallback handling
- Load test and monitor
βββββββββββββββββββββββββββββ
π Final Thoughts
Elasticsearch isn't overkill. It's the right tool for the job when:
- You have >10k searchable records
- Users expect instant results
- You need typo tolerance or relevance scoring
- Your MySQL search is becoming a bottleneck
"Switching to Elasticsearch dropped our search response time from 3.8s to 41ms. Conversion rate increased 23% because users actually found what they wanted." - Real client quote
The setup takes 15 minutes. The performance gains last forever.
βββββββββββββββββββββββββββββ
What's your search horror story? Still using LIKE queries at scale? Share your experience below! π
Already using Elasticsearch? What's your biggest challenge with it?
Found this helpful? Save it for your next Laravel project and follow for more Laravel deep dives!
βββββββββββββββββββββββββββββββ
Top comments (0)