Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Scout Elasticsearch Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Index-name collisions silently corrupt hydration. Two models returning the same searchableAs() means hits resolve to the wrong class. Audit searchableAs() across all searchable models before your first multi-model query.
  • Mappings are guessed on first write. Dynamic mapping will turn "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.
  • Near-real-time means stale reads. After a write or import, a search issued immediately may miss the new document. Refresh explicitly in tests and after bulk imports; in production, accept the ~1s lag or refresh on demand for write-then-read flows.
  • Total counts are approximate by default. Pagination totals can be capped by 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.
  • Client/cluster version mismatch is the #1 install failure. 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.
  • Soft deletes and missing models. Hits referencing rows deleted outside Scout's event flow hydrate to 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.
  • Debugging tip: log the final request body. Wrap the client or use ES's slowlog to see the actual query; nearly every "wrong results" bug is a mapping/analyzer issue, not a builder issue.
  • Extension points: bind your own 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.
  • Maintenance risk is real. 742 stars, effectively a single-maintainer wrapper around the official client. Pin the version in 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib