matchish/laravel-scout-elasticsearch
Elasticsearch driver for Laravel Scout, replacing the default engine with a full-featured Elasticsearch backend. Keep Scout's simple model-based API while gaining fast full-text search, filtering, aggregations, and multi-index queries. Straightforward to configure and scale.
1. Install the package alongside Scout and the ES client
composer require matchish/laravel-scout-elasticsearch
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
Laravel auto-discovers the service provider. The package swaps Scout's engine for Matchish\ScoutElasticSearch\Engines\ElasticsearchEngine when you set the elasticsearch driver.
2. Point Scout at Elasticsearch
.env:
SCOUT_DRIVER=elasticsearch
SCOUT_ELASTICSEARCH_HOSTS=http://localhost:9200
SCOUT_QUEUE=true
config/scout.php:
'driver' => env('SCOUT_DRIVER', 'elasticsearch'),
'elasticsearch' => [
'hosts' => explode(',', env('SCOUT_ELASTICSEARCH_HOSTS', 'http://localhost:9200')),
],
If you need auth, TLS, or Elastic Cloud, bind your own client in a service provider — this is the cleanest extension point:
// AppServiceProvider::register()
$this->app->singleton(\Elasticsearch\Client::class, function () {
return \Elasticsearch\ClientBuilder::create()
->setHosts(config('scout.elasticsearch.hosts'))
->setBasicAuthentication(env('ES_USER'), env('ES_PASSWORD'))
->build();
});
3. Make one model searchable
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'brand' => $this->brand?->name,
'price' => (float) $this->price,
'in_stock' => (bool) $this->stock > 0,
];
}
public function searchableAs(): string
{
return 'products';
}
}
4. Import and query
php artisan scout:import "App\Models\Product"
Product::search('wireless headphones')->where('in_stock', true)->get();
5. The reason you're here — one query across models
use Matchish\ScoutElasticSearch\Search;
$results = Search::search('wireless')
->within(Product::class, Article::class, FaqEntry::class)
->take(20)
->get();
That single call is the package's selling point: it fans out to multiple indices in one round trip and hydrates hits back into the right Eloquent models, so _score is comparable across result types instead of being stitched together from N separate searches.
Where to look first: the README's multi-model section for the exact builder surface in your pinned version, then Matchish\ScoutElasticSearch\Engines\ElasticsearchEngine (it's small — reading it tells you exactly which Scout methods are implemented and which throw), and config/scout.php for host wiring.
First use case: a global site search box (products + articles + help pages) with a "type" badge on each hit and one relevance ordering.
Multi-model search with filters and pagination
$page = Search::search($request->q)
->within(Product::class, Article::class)
->where('status', 'published')
->where('price', '<', 500)
->paginate(24);
// Each item hydrates to its own model class — normalise before rendering:
$page->through(fn ($model) => [
'type' => class_basename($model),
'title' => $model->name ?? $model->title,
'url' => $model->url(),
'score' => $model->search_score ?? null,
]);
Always normalise heterogeneous results at the API layer. Views and front-ends break fast when they assume one model shape.
Boosts and relevance tuning — pass raw ES params through options() / raw() rather than fighting the fluent builder:
Search::search($q)
->within(Product::class, Article::class)
->options([
'indices_boost' => [['products' => 2.0], ['articles' => 1.0]],
'track_total_hits' => true,
'highlight' => ['fields' => ['name' => new \stdClass, 'body' => new \stdClass]],
])
->raw();
Use ->raw() when you need the ES response envelope (highlights, aggregations, _score), and the hydrated builder when you just need models.
One index per model, distinct names. searchableAs() is the contract that lets the package map a hit's _index back to a model class. Keep names stable and unique per model — treat them as API. If you need schema changes without downtime, point searchableAs() at an alias (products_live) and reindex into products_v2, then flip the alias atomically.
Queued indexing is the default for writes. With SCOUT_QUEUE=true, saves dispatch an indexing job; the request doesn't block on ES. Keep the queue worker running and monitor failed jobs — a dead worker means silently stale search results. For bulk loads, disable queuing and run the import directly:
php artisan scout:import "App\Models\Product"
php artisan scout:flush "App\Models\Product" # only when you truly need a clean slate
Keep toSearchableArray() a projection, not a dump. It's called on every index operation and serialised to JSON. Include only queryable/filterable/displayable fields; eager-load relations used inside it via makeAllSearchableUsing():
public function makeAllSearchableUsing($query)
{
return $query->with('brand')->where('status', 'published');
}
That same query builder is what scout:import walks, so it's also your lever for excluding rows (drafts, soft-deleted) from the index entirely.
Indexing lifecycle hooks. Use shouldBeSearchable() to keep non-public records out, and Searchable::withoutSyncingToSearch() around data migrations and seeders so you don't hammer the cluster:
Product::withoutSyncingToSearch(fn () => Product::factory()->count(5000)->create());
Testing pattern. There is no engine fake equivalent to Scout's Algolia fake, so tests need a real cluster (docker-compose or testcontainers) plus explicit refresh:
protected function setUp(): void
{
parent::setUp();
Product::removeAllFromSearch();
Product::makeAllSearchable();
// force a refresh so the next search sees the docs
app(\Elasticsearch\Client::class)->indices()->refresh(['index' => '_all']);
}
Otherwise you'll chase flaky "document not found" failures caused by the ~1s refresh interval.
Wrap it behind a service. Because the fluent surface is package-specific, put Search::search(...) calls in one SearchService/SearchRepository class. That gives you a single swap point if you later move to raw elasticsearch/elasticsearch, Meilisearch, or Typesense.
searchableAs() means hits resolve to the wrong class. Audit searchableAs() across all searchable models before your first multi-model query."123" into a text field and then reject where('sku', 123) later. Define explicit mappings/analyzers for anything you filter or sort on, or you'll be reindexing under pressure.where() is not the ES query DSL. The fluent builder supports a limited subset (keyword/numeric comparisons). Nested queries, must/should combinations, function scores, and aggregations go through options()/raw() — don't waste time trying to express them fluently.track_total_hits; set it explicitly if you show "1,234 results" in the UI._score is only comparable within one query. Boosting across indices (indices_boost) is how you encode "products outrank articles"; don't try to normalise scores in PHP after the fact.elasticsearch/elasticsearch major must match the cluster major (7.x client → 7.x cluster, 8.x → 8.x). Also check the package's supported Laravel Scout range before upgrading Scout — the engine implements Scout's Engine contract and a Scout major can break it.null or disappear depending on version. Prefer deleting via Eloquent (fires the event) and add a shouldBeSearchable() guard for anything hidden by global scopes.scout:import on big tables. It's chunked but memory-hungry with eager-loaded relations. Run it off-peak, with the queue disabled, and consider --chunk tuning; watch PHP memory and ES bulk-rejection (429) logs.php artisan config:cache traps. Hosts read from env() at runtime will be frozen. Read config through config('scout.elasticsearch.*'), never env() directly in code.Elasticsearch\Client, override the engine class and rebind it in the container, customise per-model indexing via searchableAs() / toSearchableArray() / makeAllSearchableUsing(), and hook Searchable events for index lifecycle.composer.json, keep your usage behind a service class, and budget a day to migrate to raw elasticsearch/elasticsearch if the package lags a Laravel/Scout major. The underlying query is straightforward — the value you're buying is the multi-index hydration, which is also the part you'd most miss.How can I help you explore Laravel packages today?