DEV Community

Cover image for # πŸ” Your Laravel Search Takes 8 Seconds? Here's How I Cut It to 47ms with Elasticsearch
Igor Nosatov
Igor Nosatov

Posted on

# πŸ” Your Laravel Search Takes 8 Seconds? Here's How I Cut It to 47ms with Elasticsearch

  • πŸ’‘ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Configure .env:

SCOUT_DRIVER=elasticsearch
SCOUT_PREFIX=${APP_NAME}_
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
SCOUT_QUEUE=true  # πŸ”₯ Index asynchronously
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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
    }
}
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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
Enter fullscreen mode Exit fullscreen mode

What's happening behind the scenes:

  1. Laravel loads your models in chunks
  2. Calls toSearchableArray() on each
  3. Bulk indexes to Elasticsearch
  4. 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.
Enter fullscreen mode Exit fullscreen mode

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 πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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();
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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" πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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 }
]
*/
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

πŸ”₯ 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']
                ]
            ]
        ]
    ]
];
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Gotcha #2: Not Handling Elasticsearch Downtime

// ❌ WRONG: App crashes if Elasticsearch is down
$results = Product::search($query)->get();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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"
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Spin up Elasticsearch (Docker or Elastic Cloud)
  2. Install Scout + Elasticsearch driver
  3. Make your main model searchable
  4. Import existing data

Week 2: Enhance

  1. Add filters and sorting
  2. Implement autocomplete
  3. Configure custom analyzers
  4. Add search analytics

Week 3: Optimize

  1. Fine-tune relevance scoring
  2. Set up queued indexing
  3. Add fallback handling
  4. 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!

═══════════════════════════════

πŸ“Œ Resources

Laravel #Elasticsearch #PHP #WebDev #Performance #SearchOptimization #Backend

Top comments (0)