kiamars-mirzaee / elasticsearch-eloquent
An elegant Eloquent-style query builder for Elasticsearch in Laravel
Package info
github.com/kiamars-mirzaee/elasticsearch-eloquent
pkg:composer/kiamars-mirzaee/elasticsearch-eloquent
Requires
- php: ^8.1
- elasticsearch/elasticsearch: ^8.0
- illuminate/database: ^10.0|^11.0
- illuminate/support: ^10.0|^11.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0
- phpunit/phpunit: ^10.0
This package is auto-updated.
Last update: 2026-03-17 10:23:09 UTC
README
An elegant Eloquent-style query builder for Elasticsearch in Laravel. Write Elasticsearch queries using familiar Laravel syntax.
Features
✅ Eloquent-style Query Builder - Familiar Laravel syntax for Elasticsearch
✅ Comprehensive Where Clauses - where, whereIn, whereNull, whereNot, whereBetween
✅ Nested Object Support - Query nested objects with whereNested()
✅ Full-Text Search - Powerful search with search() and matchPhrase()
✅ Aggregations - termsAgg(), sumAgg(), avgAgg(), minAgg(), maxAgg()
✅ Sorting & Pagination - orderBy(), latest(), paginate()
✅ Source Filtering - Select specific fields with select()
✅ Type Casting - Automatic type casting for attributes
✅ Model Scopes - Define reusable query scopes
Installation
Install via Composer:
composer require kiamars-mirzaee/elasticsearch-eloquent
Publish Configuration
php artisan vendor:publish --tag=elasticsearch-config
Environment Variables
Add to your .env:
ELASTICSEARCH_HOST=localhost:9200 ELASTICSEARCH_USERNAME= ELASTICSEARCH_PASSWORD=
Quick Start
1. Create Your Model
<?php namespace App\Models; use ElasticsearchEloquent\Model; class Product extends Model { protected string $index = 'products'; protected array $casts = [ 'price' => 'float', 'in_stock' => 'boolean', 'cat_id' => 'array', ]; }
2. Query Your Data
// Simple where clause $products = Product::where('in_stock', true)->get(); // Multiple conditions $products = Product::where('price', '>', 100) ->where('price', '<', 500) ->orderBy('price', 'asc') ->get(); // Full-text search $products = Product::search('laptop', ['name', 'description']) ->where('in_stock', true) ->paginate(20);
🚨 Scout Pitfalls in Production (And How This Package Solves Them)
If you've used Laravel Scout in production, you've likely encountered these challenges. Here's what breaks at scale—and how Elasticsearch Eloquent handles it differently.
❌ Pitfall 1: Dynamic Mapping Surprises
The Problem: Scout relies on Elasticsearch's dynamic mapping. Elasticsearch guesses field types based on the first document it sees:
- A timestamp might become a
textfield instead ofdate - A float might become
long, breaking range queries - Changing a field type later requires full reindexing
Real Production Scenario:
// First product has price "99.99" → mapped as text Product::create(['name' => 'Book', 'price' => '99.99']); // Later, range query fails silently or returns wrong results Product::search()->where('price', '>', 100)->get(); // ❌ Broken
How Elasticsearch Eloquent Solves This:
class Product extends Model { protected array $mapping = [ 'properties' => [ 'price' => ['type' => 'float'], 'created_at' => ['type' => 'date'], 'in_stock' => ['type' => 'boolean'], ] ]; } // Explicit mappings = predictable behavior Product::where('price', '>', 100)->get(); // ✅ Works correctly
Why This Matters:
- Mappings are source of truth in your codebase
- No silent failures in production
- Schema evolution is intentional, not accidental
❌ Pitfall 2: Nested Object Chaos
The Problem: Scout doesn't understand nested objects. Denormalized data (common in Elasticsearch) becomes impossible to query correctly.
Real Production Scenario:
// Your Elasticsearch document { "name": "Laptop", "categories": [ {"id": 1, "name": "Electronics"}, {"id": 5, "name": "Computers"} ] }
// Scout can't do this properly // You end up writing raw Elasticsearch queries in controllers $client->search([ 'index' => 'products', 'body' => [ 'query' => [ 'nested' => [ 'path' => 'categories', 'query' => ['term' => ['categories.id' => 5]] ] ] ] ]);
How Elasticsearch Eloquent Solves This:
// Clean, readable, testable Product::whereNested('categories', function ($query) { $query->where('categories.id', 5); })->get(); // Multiple nested conditions Product::whereNested('categories', function ($query) { $query->where('categories.name', 'Electronics') ->where('categories.featured', true); })->get();
Why This Matters:
- No raw arrays in controllers
- Testable query logic
- Reusable via model scopes
❌ Pitfall 3: No Aggregation Support
The Problem: Scout is built for search, not analytics. Want to know the average price? Top-selling categories? You're back to raw Elasticsearch queries.
Real Production Scenario:
// Scout forces you to drop down to raw queries $client = app(\Elastic\Elasticsearch\Client::class); $results = $client->search([ 'index' => 'products', 'body' => [ 'size' => 0, 'aggs' => [ 'avg_price' => ['avg' => ['field' => 'price']], 'categories' => ['terms' => ['field' => 'category_id']] ] ] ]);
How Elasticsearch Eloquent Solves This:
$query = Product::where('in_stock', true) ->avgAgg('average_price', 'price') ->termsAgg('top_categories', 'category_id', 10) ->sumAgg('total_inventory_value', 'price'); $products = $query->get(); $stats = $query->getAggregations(); echo $stats['average_price']['value']; // ["buckets" => [["key" => 1, "doc_count" => 150], ...]]
Why This Matters:
- Analytics in the same query as results
- No context switching between search and stats
- Stays inside Laravel's mental model
❌ Pitfall 4: Broken Bulk Updates
The Problem:
Scout syncs via model events (saved, deleted). Bulk updates bypass these events entirely.
Real Production Scenario:
// This updates MySQL but NOT Elasticsearch Product::where('category_id', 5)->update(['featured' => true]); // Your search results are now stale Product::search('laptop')->where('featured', true)->get(); // ❌ Missing data
Scout's "Solution": Manually re-sync thousands of records:
Product::where('category_id', 5)->searchable(); // Slow, blocks requests
How Elasticsearch Eloquent Solves This:
// Option 1: Trait-based auto-sync (via queues) use ElasticsearchEloquent\Concerns\Searchable; class Product extends EloquentModel { use Searchable; protected static $searchableAs = \App\SearchModels\Product::class; } // Option 2: Explicit bulk sync job Product::where('category_id', 5) ->chunk(1000, function ($products) { BulkSyncToElasticsearch::dispatch($products); });
Why This Matters:
- Queues prevent blocking user requests
- Explicit control over sync strategy
- Designed for high-throughput systems
❌ Pitfall 5: No Zero-Downtime Reindexing
The Problem:
Scout writes directly to products index. To change mappings (e.g., text → keyword), you must:
- Drop the index → Downtime ❌
- Recreate with new mappings
- Re-sync all data → More downtime ❌
Real Production Scenario:
# Your app breaks during this process curl -X DELETE localhost:9200/products curl -X PUT localhost:9200/products -d '{"mappings": {...}}' php artisan scout:import "App\Models\Product" # 30+ minutes
How Elasticsearch Eloquent Solves This:
// Use aliases (industry standard pattern) // 1. Create new index Product::createIndex('products_v2', $newMapping); // 2. Reindex data (background job) ReindexJob::dispatch('products_v1', 'products_v2'); // 3. Atomic alias swap (zero downtime) Product::swapAlias('products', 'products_v1', 'products_v2'); // 4. Delete old index Product::deleteIndex('products_v1');
Why This Matters:
- No downtime during schema changes
- Production-safe deployments
- Rollback safety (keep old index until verified)
❌ Pitfall 6: Limited Query Expressiveness
The Problem: Scout's query builder is intentionally simple. Complex scoring, boosting, or multi-field queries require dropping into raw Elasticsearch DSL.
Real Production Scenario:
// Scout can't express this cleanly $client->search([ 'index' => 'products', 'body' => [ 'query' => [ 'bool' => [ 'should' => [ ['match' => ['name' => ['query' => 'laptop', 'boost' => 3]]], ['match' => ['description' => 'laptop']], ], 'filter' => [ ['term' => ['in_stock' => true]], ['range' => ['price' => ['lte' => 2000]]] ] ] ] ] ]);
How Elasticsearch Eloquent Solves This:
// Option 1: Expressive API Product::search('laptop', ['name^3', 'description']) ->where('in_stock', true) ->where('price', '<=', 2000) ->get(); // Option 2: Model scopes for reusability Product::smartSearch('title', 'laptop') ->inStock() ->get(); // Option 3: Raw DSL when needed (escape hatch) Product::whereRaw([ 'bool' => [ 'should' => [ ['prefix' => ['name.keyword' => ['value' => 'lap', 'boost' => 10]]], ['match_phrase_prefix' => ['name' => 'laptop']], ] ] ])->get();
Why This Matters:
- Start simple, grow into complexity
- Escape hatch without leaving the query builder
- Reusable logic via scopes
❌ Pitfall 7: Analyzer and Tokenization Blindness
The Problem: Scout doesn't expose Elasticsearch's analyzer system. Multi-language search, autocomplete, or stemming requires manual index configuration outside Laravel.
Real Production Scenario:
// Persian/Arabic users search for "لپتاپ" // Scout's default analyzer fails because it doesn't handle: // - Right-to-left text // - Diacritics normalization // - Language-specific stemming
How Elasticsearch Eloquent Solves This:
class Product extends Model { protected array $settings = [ 'analysis' => [ 'analyzer' => [ 'persian_normalized' => [ 'type' => 'custom', 'tokenizer' => 'standard', 'filter' => ['lowercase', 'arabic_normalization', 'persian_normalization'] ] ] ] ]; protected array $mapping = [ 'properties' => [ 'title' => [ 'type' => 'text', 'fields' => [ 'normalized' => ['type' => 'text', 'analyzer' => 'persian_normalized'], 'keyword' => ['type' => 'keyword'] ] ] ] ]; } // Now search works correctly for Persian users Product::smartSearch('title', 'لپتاپ')->get();
Why This Matters:
- Analyzers are code, not external config
- Multi-language apps are first-class
- Autocomplete, stemming, n-grams are accessible
📋 TL;DR: Scout → Elasticsearch Eloquent Migration Checklist
When to Migrate
Migrate from Scout if you're experiencing:
- Incorrect search results due to dynamic mapping issues
- Need for nested object queries (denormalized data)
- Requirement for aggregations/analytics alongside search
- Bulk update sync failures
- Downtime during mapping changes
- Multi-language search challenges
- Complex scoring/boosting needs
Migration Steps
1. Install Package
composer require kiamars-mirzaee/elasticsearch-eloquent php artisan vendor:publish --tag=elasticsearch-config
2. Create Search Models (Separate from Eloquent Models)
// app/SearchModels/Product.php namespace App\SearchModels; use ElasticsearchEloquent\Model; class Product extends Model { protected string $index = 'products'; // ✅ Explicit mappings (no dynamic mapping surprises) protected array $mapping = [ 'properties' => [ 'id' => ['type' => 'keyword'], 'name' => ['type' => 'text'], 'price' => ['type' => 'float'], 'in_stock' => ['type' => 'boolean'], 'created_at' => ['type' => 'date'], ] ]; // ✅ Custom analyzers for your use case protected array $settings = [ 'number_of_shards' => 2, 'number_of_replicas' => 1, ]; protected array $casts = [ 'price' => 'float', 'in_stock' => 'boolean', ]; }
3. Add Searchable Trait to Eloquent Models (Optional Auto-Sync)
// app/Models/Product.php (your database model) namespace App\Models; use Illuminate\Database\Eloquent\Model; use ElasticsearchEloquent\Concerns\Searchable; class Product extends Model { use Searchable; // Link to your search model protected static $searchableAs = \App\SearchModels\Product::class; // Optional: customize what data gets indexed public function toSearchableArray(): array { return [ 'id' => $this->id, 'name' => $this->name, 'price' => $this->price, 'in_stock' => $this->in_stock, 'created_at' => $this->created_at?->toIso8601String(), ]; } }
4. Create Index with Mappings
php artisan tinker
\App\SearchModels\Product::createIndex();
5. Initial Data Migration
// Option 1: Bulk sync via chunk (recommended) \App\Models\Product::chunk(1000, function ($products) { \App\Jobs\BulkSyncToElasticsearch::dispatch($products); }); // Option 2: One-time manual sync \App\Models\Product::all()->each(function ($product) { $product->searchable(); // If using Searchable trait });
6. Update Queries in Controllers
Before (Scout):
// Limited expressiveness $products = Product::search($request->query) ->where('in_stock', 1) ->paginate();
After (Elasticsearch Eloquent):
// Full power of Elasticsearch $products = \App\SearchModels\Product::search($request->query, ['name', 'description']) ->where('in_stock', true) ->whereBetween('price', [$minPrice, $maxPrice]) ->whereNested('categories', function ($q) use ($categoryId) { $q->where('categories.id', $categoryId); }) ->termsAgg('top_brands', 'brand.name', 10) ->orderBy('_score', 'desc') ->paginate(20);
7. Verify Sync is Working
// Create a product $product = Product::create([...]); // Check it appears in Elasticsearch (within queue delay) sleep(2); // Wait for queue to process $found = \App\SearchModels\Product::where('id', $product->id)->first(); dd($found); // Should show your product
8. Remove Scout (Optional)
composer remove laravel/scout
Remove Scout config and service provider references.
Common Migration Patterns
Pattern 1: Keep Both (Gradual Migration)
// Keep Scout for simple searches use Laravel\Scout\Searchable as ScoutSearchable; // Use Elasticsearch Eloquent for complex searches use ElasticsearchEloquent\Concerns\Searchable as ElasticSearchable; class Product extends Model { use ScoutSearchable, ElasticSearchable; protected static $searchableAs = \App\SearchModels\Product::class; }
Pattern 2: Separate Search Logic
// app/Services/ProductSearchService.php class ProductSearchService { public function search(array $filters) { $query = SearchProduct::query(); if ($filters['query'] ?? null) { $query->search($filters['query'], ['name', 'description']); } if ($filters['price_min'] ?? null) { $query->where('price', '>=', $filters['price_min']); } return $query->paginate(20); } }
Pattern 3: Model Scopes for Reusability
// app/SearchModels/Product.php public function scopeInStock($query) { return $query->where('in_stock', true); } public function scopePriceRange($query, $min, $max) { return $query->whereBetween('price', [$min, $max]); } // Usage Product::inStock()->priceRange(100, 500)->get();
Performance Checklist
After migration:
- Verify mappings are correct (
GET /products/_mapping) - Test query performance (
/_search?explain=true) - Monitor slow queries (Elasticsearch slow log)
- Set up index aliases for zero-downtime updates
- Configure replica count based on read load
- Add monitoring (Kibana or similar)
- Test sync under load (queue monitoring)
Documentation
Basic Where Clauses
// Equal Product::where('name', 'Laptop')->get(); Product::where('price', 999.99)->get(); // Operators Product::where('price', '>', 500)->get(); Product::where('price', '<=', 1000)->get(); // Multiple conditions (AND) Product::where('in_stock', true) ->where('price', '>', 100) ->get(); // Array of conditions Product::where([ 'in_stock' => true, 'featured' => true, ])->get(); // OR conditions Product::where('category', 'Electronics') ->orWhere('category', 'Computers') ->get();
Where In / Not In
// Where in Product::whereIn('cat_id', [1, 5, 12])->get(); // Where not in Product::whereNotIn('cat_id', [99, 100])->get(); // Or where in Product::where('in_stock', true) ->orWhereIn('category_id', [1, 2, 3]) ->get();
Where Null / Not Null
// Where null Product::whereNull('description')->get(); // Where not null Product::whereNotNull('description')->get(); // Or where null Product::where('price', 0) ->orWhereNull('price') ->get();
Where Not
// Where not equal Product::whereNot('status', 'archived')->get(); // Where not with operator Product::whereNot('price', '>', 5000)->get(); // Multiple where not Product::whereNot([ 'archived' => true, 'deleted' => true, ])->get();
Where Between
// Between Product::whereBetween('price', [100, 500])->get(); // Not between Product::whereNotBetween('price', [0, 10])->get(); // Date range Product::whereBetween('created_at', [ '2024-01-01', '2024-12-31', ])->get();
Nested Object Queries
Perfect for denormalized data structures:
// Your Elasticsearch document structure: { "name": "Laptop Pro", "brand": { "id": 10, "name": "TechBrand", "country": "USA" }, "categories": [ {"id": 1, "name": "Electronics"}, {"id": 5, "name": "Computers"} ], "tags": [ {"id": 20, "name": "laptop"}, {"id": 21, "name": "professional"} ] } // Query nested brand Product::whereNested('brand', function ($query) { $query->where('brand.name', 'TechBrand') ->where('brand.country', 'USA'); })->get(); // Query nested categories array Product::whereNested('categories', function ($query) { $query->where('categories.name', 'Electronics'); })->get(); // Query nested tags Product::whereNested('tags', function ($query) { $query->whereIn('tags.name', ['laptop', 'gaming']); })->get();
Full-Text Search
// Search across all fields Product::search('gaming laptop')->get(); // Search specific fields Product::search('laptop', ['name', 'description'])->get(); // Search with filters Product::search('laptop', ['name', 'description']) ->where('price', '<', 2000) ->where('in_stock', true) ->paginate(20); // Match phrase (exact phrase) Product::matchPhrase('description', 'high performance')->get(); // Minimum score threshold Product::search('laptop') ->minScore(1.5) ->get();
Sorting
// Order by Product::orderBy('price', 'asc')->get(); Product::orderBy('created_at', 'desc')->get(); // Order by descending Product::orderByDesc('price')->get(); // Multiple sorts Product::orderBy('in_stock', 'desc') ->orderBy('price', 'asc') ->get(); // Latest/Oldest helpers Product::latest('created_at')->get(); Product::oldest('created_at')->get();
Limiting & Pagination
// Take/Limit Product::take(10)->get(); Product::limit(10)->get(); // Skip/Offset Product::skip(20)->take(10)->get(); Product::offset(20)->limit(10)->get(); // First result $product = Product::where('name', 'Laptop')->first(); // Pagination $products = Product::where('in_stock', true) ->orderBy('price', 'desc') ->paginate(15); // Custom page $products = Product::paginate(20, ['*'], 'page', 2);
Selecting Fields
// Select specific fields (source filtering) Product::select(['name', 'price', 'brand'])->get(); // Get with columns Product::where('in_stock', true)->get(['name', 'price']);
Aggregations
// Terms aggregation (category distribution) $aggs = Product::query() ->termsAgg('categories', 'cat_id', 20) ->getAggregations(); // Sum aggregation $aggs = Product::query() ->sumAgg('total_value', 'price') ->getAggregations(); // Average $aggs = Product::query() ->avgAgg('average_price', 'price') ->getAggregations(); // Min & Max $aggs = Product::query() ->minAgg('min_price', 'price') ->maxAgg('max_price', 'price') ->getAggregations(); // Multiple aggregations $aggs = Product::query() ->where('in_stock', true) ->termsAgg('top_brands', 'brand.name', 10) ->avgAgg('avg_price', 'price') ->sumAgg('total_value', 'price') ->getAggregations();
Count
// Count all $count = Product::count(); // Count with conditions $count = Product::where('in_stock', true)->count(); $count = Product::whereIn('cat_id', [1, 2, 3])->count();
Model Scopes
Define reusable query logic:
class Product extends Model { public function scopeInStock($query) { return $query->where('in_stock', true); } public function scopeByBrand($query, string $brandName) { return $query->whereNested('brand', function ($q) use ($brandName) { $q->where('brand.name', $brandName); }); } public function scopeInCategory($query, int $categoryId) { return $query->whereIn('cat_id', [$categoryId]); } } // Use scopes Product::inStock()->get(); Product::byBrand('TechBrand')->get(); Product::inCategory(5)->get(); // Chain scopes Product::inStock() ->byBrand('TechBrand') ->orderBy('price', 'asc') ->get();
Complex Examples
Example 1: E-commerce Product Search
$products = Product::search('laptop', ['name', 'description']) ->where('in_stock', true) ->whereBetween('price', [500, 2000]) ->whereNested('brand', function ($query) { $query->where('brand.country', 'USA'); }) ->whereNotIn('cat_id', [99, 100]) // Exclude archived ->orderBy('price', 'asc') ->paginate(20);
Example 2: Search with Analytics
$query = Product::search('electronics') ->whereIn('cat_id', [1, 5, 12]) ->where('price', '>', 100) ->termsAgg('top_brands', 'brand.name', 10) ->avgAgg('average_price', 'price') ->sumAgg('total_inventory', 'stock_count'); $products = $query->get(); $stats = $query->getAggregations(); // Access aggregations $topBrands = $stats['top_brands']['buckets']; $avgPrice = $stats['average_price']['value'];
Example 3: Multi-Condition Filtering
$products = Product::where('in_stock', true) ->whereNested('categories', function ($query) { $query->where('categories.slug', 'electronics'); }) ->whereNested('tags', function ($query) { $query->whereIn('tags.name', ['featured', 'bestseller']); }) ->whereNotNull('description') ->whereBetween('price', [100, 1000]) ->latest('created_at') ->select(['name', 'price', 'brand', 'categories']) ->paginate(25);
Working with Results
$products = Product::where('in_stock', true)->get(); foreach ($products as $product) { // Access attributes echo $product->name; echo $product->price; // Access nested objects echo $product->brand['name']; // Access nested arrays foreach ($product->categories as $category) { echo $category['name']; } } // Convert to array $array = $product->toArray(); // Check existence if ($product->exists) { // Product exists in Elasticsearch }
Type Casting
class Product extends Model { protected array $casts = [ 'price' => 'float', 'in_stock' => 'boolean', 'cat_id' => 'array', 'categories' => 'array', 'tags' => 'array', 'metadata' => 'json', ]; }
Performance Tips
// ✅ Use select() for better performance Product::select(['name', 'price']) ->where('in_stock', true) ->get(); // ✅ Use count() instead of get()->count() $count = Product::where('in_stock', true)->count(); // ✅ Use pagination for large datasets $products = Product::paginate(50); // ✅ Use aggregations for analytics $stats = Product::query() ->avgAgg('avg_price', 'price') ->getAggregations();
Advance search with whereRaw
How to Use (Model Scope) Don't write that massive array in your Controller. Use a Model Scope to encapsulate the logic. This keeps your code clean and reusable.
Add this method to your Product model (or whatever Model you are searching):
// In App/SearchModels/Product.php public function scopeSmartSearch($query, string $fieldName, string $searchTerm) { // Define the complex raw query logic here $rawQuery = [ 'bool' => [ 'should' => [ [ 'prefix' => [ $fieldName . '.kw' => [ 'value' => $searchTerm, 'boost' => 10, ], ], ], [ 'match_phrase_prefix' => [ $fieldName . '.normalized' => [ 'query' => $searchTerm, 'analyzer' => 'persian_normalized_analyzer', 'boost' => 8, ], ], ], [ 'match_phrase' => [ $fieldName . '.normalized' => [ 'query' => $searchTerm, 'analyzer' => 'persian_normalized_analyzer', 'boost' => 7, ], ], ], // ... add other clauses here ... [ 'match' => [ $fieldName . '.normalized' => [ 'query' => $searchTerm, 'analyzer' => 'persian_normalized_analyzer', 'boost' => 2, 'operator' => 'and', ], ], ], ], 'minimum_should_match' => 1, ], ]; // Pass it to the new whereRaw method return $query->whereRaw($rawQuery); }
Now you can use it cleanly in your application:
$products = Product::smartSearch('title', 'my search query') ->where('in_stock', true) ->paginate(20);
Advice for Senior Engineers
-
Mappings as Code (Source of Truth): Elasticsearch tries to be smart with "Dynamic Mapping," but in production, this is dangerous (e.g., it might guess a timestamp is just a string, or a float is an integer). Always define your mappings explicitly in your Model. This keeps your codebase as the "Source of Truth," similar to Laravel Migrations.
-
Zero-Downtime Reindexing (Aliases): The Problem: You cannot change the type of an existing field in Elasticsearch (e.g., text to keyword) without reindexing. The Solution: Never write directly to an index named products. Create an index products_v1. Create an alias products pointing to products_v1. Your app reads/writes to products. When mapping changes: Create products_v2, reindex data, switch alias products to products_v2, delete products_v1. Note: The implementation below handles direct index creation for simplicity, but you can extend IndexManager to handle alias swapping later.
-
Settings Matter: Don't forget settings. This is where you define your Analyzers (e.g., n-grams for partial matching) and Replicas (for high availability).
Sync Database Model with Elastic
when you need to sync a Relational Database (SQL) with a NoSQL search engine like Elasticsearch, you must solve for Consistency, Throughput, and Reliability.
For a Laravel-based portfolio, the most professional architecture isn't just "calling a function on save." It's a Trait-based Observer pattern using the Outbox pattern (Queues).
Here is the implementation plan to automate this, including support for bulk updates.
- The Searchable Trait We create a trait for your Eloquent models (the database models) that automatically hooks into Laravel's model events.
- The High-Performance Sync Job This job handles the actual communication with your Elasticsearch Model. It uses ShouldQueue to ensure database transactions aren't blocked by network latency.
- Handling Bulk Updates (The "Senior" Way) Laravel's saved event doesn't fire for bulk queries like Product::where('active', 1)->update(['price' => 10]). To solve this, we add a Bulk helper to your Builder.php.
Why This Exists (And How It Differs from Laravel Scout)
Laravel Scout is an excellent starting point for search in Laravel. It's simple, familiar, and works well for basic full-text search and syncing models to search engines.
However, as applications grow, teams often hit Scout's natural limits:
-
Limited control over Elasticsearch mappings and analyzers
-
Minimal support for nested objects and complex queries
-
No first-class story for zero-downtime reindexing
-
Difficult to express advanced scoring, aggregations, or analytics queries
-
Sync logic that can become fragile under heavy load or queue failures
Elasticsearch Eloquent exists for teams that have outgrown Scout.
This package does not try to replace Scout's simplicity. Instead, it provides a lower-level, production-focused abstraction for Elasticsearch that gives you full control while keeping Laravel ergonomics.
Key Differences
-
Query-first, not sync-first
-
Scout focuses on syncing Eloquent models to search engines. Elasticsearch Eloquent focuses on querying Elasticsearch as a primary data source, using an Eloquent-style API.
-
Explicit mappings and analyzers
-
Field types, analyzers, and nested structures are defined intentionally. No reliance on dynamic mapping or hidden defaults.
-
First-class support for complex queries
-
Nested queries, aggregations, scoring, and raw Elasticsearch DSL are supported without escaping into controllers.
-
Zero-downtime indexing patterns
-
The architecture encourages alias-based indexing and reindexing, making schema evolution safe in production.
-
Designed for stronger consistency guarantees
-
The package is built to integrate cleanly with queue-based syncing and Outbox-pattern workflows for systems where data correctness matters.
In short:
Scout is great for getting search working. Elasticsearch Eloquent is for keeping search working as your system scales.
If you need deep Elasticsearch control while staying inside Laravel's mental model, this package is built for that stage of growth.
| Feature / Concern | Laravel Scout | Elasticsearch Eloquent |
|---|---|---|
| Primary Goal | Simple model syncing & basic search | Production-grade Elasticsearch querying |
| Learning Curve | Very low | Moderate (intentional control) |
| Query Style | Limited, engine-dependent | Eloquent-style, expressive query builder |
| Elasticsearch DSL Access | Minimal / indirect | Full access via whereRaw() |
| Nested Object Queries | Limited | First-class support |
| Aggregations & Analytics | ❌ Not supported | ✅ Fully supported |
| Custom Scoring & Boosting | Limited | ✅ Supported |
| Explicit Mappings | ❌ No | ✅ Yes (source of truth) |
| Custom Analyzers (e.g. multilingual) | Limited | ✅ First-class support |
| Dynamic Mapping Reliance | High | None (explicit by design) |
| Zero-Downtime Reindexing (Aliases) | ❌ Manual | ✅ Architecture-friendly |
| Query as Primary Data Source | ❌ Not intended | ✅ Designed for it |
| Sync Strategy | Model events | Queue & Outbox-friendly |
| Bulk Update Handling | ❌ Limited | ✅ Designed for it |
| Controller-Free Complex Search | ❌ Difficult | ✅ Model scopes & raw queries |
| Best For | Small–medium apps, simple search | Large apps, complex search, production systems |
Requirements
- PHP 8.1 or higher
- Laravel 10.0 or 11.0
- Elasticsearch 8.0 or higher
Testing
composer test
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This package is open-sourced software licensed under the MIT license.
Credits
Created by Kiamars mirzaee
Support
- 📧 Email: kiamars-mirzaee@gmail.com
- 🐛 Issues: GitHub Issues
- 📖 Documentation: Full Documentation