matchish / laravel-scout-elasticsearch
Search among multiple models with ElasticSearch and Laravel Scout
Package info
github.com/matchish/laravel-scout-elasticsearch
pkg:composer/matchish/laravel-scout-elasticsearch
Fund package maintenance!
Requires
- php: ^8.0.12|^8.1|^8.2
- elasticsearch/elasticsearch: ^8.0
- handcraftedinthealps/elasticsearch-dsl: ^8.0
- laravel/scout: ^8.0|^9.0|^10.0
Requires (Dev)
- larastan/larastan: ^2.9
- laravel/legacy-factories: ^1.0
- orchestra/testbench: ^6.17|^7.0|^8.0|^9.0
- php-http/guzzle7-adapter: ^1.0
- phpunit/phpunit: ^9.5|^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 8.x-dev
- v8.0.0-alpha.4
- 8.0.0-alpha.3
- 8.0.0-alpha.2
- 8.0.0-alpha.1
- v7.14.2
- v7.14.1
- v7.14.0
- v7.13.0
- v7.12.0
- v7.11.2
- v7.11.1
- v7.11.0
- v7.10.0
- v7.9.0
- v7.8.0
- v7.7.0
- v7.6.2
- v7.6.1
- v7.6.0
- v7.5.0
- v7.4.0
- v7.3.0
- v7.2.2
- v7.2.1
- v7.2.0
- v7.1.0
- v7.0.0
- V6.0.2
- v6.0.1
- v6.0.0
- 5.x-dev
- v5.0.3
- v5.0.2
- 5.0.1
- 5.0.0
- 4.0.9
- 4.0.8
- 4.0.7
- 4.0.5
- 4.0.4
- 4.0.3
- 4.0.1
- 4.0.0
- 3.x-dev
- 3.0.6
- 3.0.5
- 3.0.4
- 3.0.3
- 3.0.2
- 3.0.1
- 3.0.0
- 2.1.0
- 2.0.3
- 2.0.2
- 2.0.1
- 2.0.0
- 1.1.0
- 1.0.0
- dev-claude/docs-working-with-results-8x
- dev-fix/search-factory-where-operators-8x
- dev-feature/parallel-import-v2
- dev-fix/import-status-records
- dev-fix/search-factory-where-operators
- dev-fix/mixed-search-soft-delete-8x
- dev-fix/mixed-search-soft-delete
- dev-fix/227-mixed-search-paginate
- dev-fix/227-mixed-search-paginate-8x
- dev-claude/adoring-robinson-47f26e
- dev-fix/273-drop-php-parser-8x
- dev-fix/273-declare-php-parser-dependency
- dev-pr-298-test
- dev-analysis-prjbnZ
- dev-spike-es7-with-7.6.0
- dev-resolve-242
- dev-matchish-issue-227
- dev-github-actions
- dev-parallel-import
- dev-php8
- dev-analysis-nN7gwO
This package is auto-updated.
Last update: 2026-11-02 07:34:50 UTC
README
Stay with Ukraine
UNITED24 launches the first fundraiser towards terrestrial robotic platforms. Squads of robots will save the lives of our military and civilians. They will become logistics devices, tow trucks, minelayers and deminers, as well as self-destructive robots. They will fight alongside people and for people.
The first robots are already proving their effectiveness on the battlefield. There will be more of them soon. Many more.
The package provides the perfect starting point to integrate ElasticSearch into your Laravel application. It is carefully crafted to simplify the usage of ElasticSearch within the Laravel Framework.
It’s built on top of the latest release of Laravel Scout, the official Laravel search package. Using this package, you are free to take advantage of all of Laravel Scout’s great features, and at the same time leverage the complete set of ElasticSearch’s search experience.
💕 Features
Don't forget to ⭐ the package if you like it. 🙏
- Laravel Scout 10.x support
- Laravel Nova support
- Search amongst multiple models
- Zero downtime reimport - it’s a breeze to import data in production.
- Eager load relations - speed up your import.
- Parallel import to make your import as fast as possible (in alpha version for now)
- Import all searchable models at once.
- A fully configurable mapping for each model.
- Full power of ElasticSearch in your queries.
⚠️ Requirements
- PHP version >= 8.2
- Laravel Framework version >= 12.0
- Laravel Scout version >= 11.0
| Elasticsearch version | ElasticsearchDSL version |
|---|---|
| >= 8.0 | >= 8.0.0 |
| >= 7.0 | >= 3.0.0 |
| >= 6.0, < 7.0 | < 3.0.0 |
🚀 Installation
Use composer to install the package:
composer require matchish/laravel-scout-elasticsearch
Set env variables
SCOUT_DRIVER=Matchish\ScoutElasticSearch\Engines\ElasticSearchEngine
The package uses \ElasticSearch\Client from official package, but does not try to configure it
beyond connection configuration, so feel free do it in your app service provider.
But if you don't want to do it right now,
you can use Matchish\ElasticSearchServiceProvider from the package.
Register the provider, adding to config/app.php
'providers' => [ // Other Service Providers \Matchish\ScoutElasticSearch\ElasticSearchServiceProvider::class ],
Set ELASTICSEARCH_HOST env variable
ELASTICSEARCH_HOST=host:port
or use commas as separator for additional nodes
ELASTICSEARCH_HOST=host:port,host:port
You can disable SSL verification by setting the following in your env
ELASTICSEARCH_SSL_VERIFICATION=false
And publish config example for elasticsearch
php artisan vendor:publish --tag config
💡 Usage
Note: This package adds functionalities to Laravel Scout, and for this reason, we encourage you to read the Scout documentation first. Documentation for Scout can be found on the Laravel website.
Index settings and mappings
It is very important to define the mapping when we create an index — an inappropriate preliminary definition and mapping may result in the wrong search results.
To define mappings or settings for index, set config with right value.
For example if method searchableAs returns
products string
Config key for mappings should be
elasticsearch.indices.mappings.products
Or you you can specify default mappings with config key
elasticsearch.indices.mappings.default
Same way you can define settings
For index products it will be
elasticsearch.indices.settings.products
And for default settings
elasticsearch.indices.settings.default
Eager load
To speed up import you can eager load relations on import using global scopes.
You should configure ImportSourceFactory in your service provider(register method)
use Matchish\ScoutElasticSearch\Searchable\ImportSourceFactory; ... public function register(): void { $this->app->bind(ImportSourceFactory::class, MyImportSourceFactory::class);
Here is an example of MyImportSourceFactory
namespace Matchish\ScoutElasticSearch\Searchable; final class MyImportSourceFactory implements ImportSourceFactory { public static function from(string $className): ImportSource { //Add all required scopes return new DefaultImportSource($className, [new WithCommentsScope()]); } } class WithCommentsScope implements Scope { /** * Apply the scope to a given Eloquent query builder. * * @param \Illuminate\Database\Eloquent\Builder $builder * @param \Illuminate\Database\Eloquent\Model $model * @return void */ public function apply(Builder $builder, Model $model) { $builder->with('comments'); } }
You can also customize your indexed data when you save models by leveraging the toSearchableArray method
provided by Laravel Scout through the Searchable trait
Example:
class Product extends Model { use Searchable; /** * Get the indexable data array for the model. * * @return array */ public function toSearchableArray() { $with = [ 'categories', ]; $this->loadMissing($with); return $this->toArray(); } }
This example will make sure the categories relationship gets always loaded on the model when saving it.
Zero downtime reimport
While working in production, to keep your existing search experience available while reimporting your data, you also can use scout:import Artisan command:
php artisan scout:import
The command create new temporary index, import all models to it, and then switch to the index and remove old index.
Search
To be fully compatible with original scout package, this package does not add new methods.
So how we can build complex queries?
There is two ways.
By default, when you pass a query to the search method, the engine builds a query_string query, so you can build queries like this
Product::search('(title:this OR description:this) AND (title:that OR description:that)')
If it's not enough in your case you can pass a callback to the query builder
$results = Product::search('zonga', function(\Elastic\Elasticsearch\Client $client, $body) { $minPriceAggregation = new MinAggregation('min_price'); $minPriceAggregation->setField('price'); $maxPriceAggregation = new MaxAggregation('max_price'); $maxPriceAggregation->setField('price'); $brandTermAggregation = new TermsAggregation('brand'); $brandTermAggregation->setField('brand'); $body->addAggregation($minPriceAggregation); $body->addAggregation($brandTermAggregation); return $client->search(['index' => 'products', 'body' => $body->toArray()])->asArray(); })->raw();
Note : The callback function will get 2 parameters. First one is
$clientand it is an object of\Elastic\Elasticsearch\Clientclass from elasticsearch/elasticsearch package. And the second one is$bodywhich is an object of\ONGR\ElasticsearchDSL\Searchfrom ongr/elasticsearch-dsl package. So, while as you can see the example above,$client->search(....)method will return an\Elastic\Elasticsearch\Response\Elasticsearchobject. And you need to useasArray()method to get array result. Otherwise, theHitsIteratorAggregateclass will throw an error. You can check the issue here.
Conditions
Scout supports only 3 conditions: ->where(column, value) (strict equation), ->whereIn(column, array) and ->whereNotIn(column, array):
Product::search('(title:this OR description:this) AND (title:that OR description:that)') ->where('price', 100) ->whereIn('type', ['used', 'like new']) ->whereNotIn('type', ['new', 'refurbished']);
Since Scout 11.1, ->where() also accepts an operator: ->where(column, operator, value).
The supported operators are =, !=, <>, >, >=, < and <=. Any other operator, for example like, throws an InvalidArgumentException.
Product::search() ->where('type', '!=', 'used') ->where('price', '>=', 100);
With a null value, ->where() checks whether the field has a value. Elasticsearch does not index null, so a field with null and a missing field are the same.
Product::search()->where('description', null); // products without a description Product::search()->where('description', '!=', null); // products with a description
For other conditions, you can pass ElasticSearch terms like RangeQuery as value to ->where():
use ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery; Product::search('(title:this OR description:this) AND (title:that OR description:that)') ->where('price', new RangeQuery('price', [ RangeQuery::GTE => 100, RangeQuery::LTE => 1000, ]));
And if you just want to search using RangeQuery without any query_string, you can call the search() method directly and leave the param empty.
use ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery; Product::search() ->where('price', new RangeQuery('price', [ RangeQuery::GTE => 100, ]));
The value is not limited to term-level queries. You can pass any ElasticSearch query object, for example a full-text QueryStringQuery:
use ONGR\ElasticsearchDSL\Query\FullText\QueryStringQuery; Product::search() ->where('q', new QueryStringQuery('this AND that', [ 'fields' => ['name', 'email'], ]));
When the value is a query object, the first argument of ->where() ('q' in this example) is ignored, because the query object already knows its fields.
Full list of ElasticSearch queries is in vendor/handcraftedinthealps/elasticsearch-dsl/src/Query (TermLevel, FullText, Compound, etc.).
Note :
->where()always adds the query to thefiltercontext of a bool query (or tomust_notwhen you use the!=operator). ElasticSearch does not calculate a score in these contexts. So such a query only filters the hits and never changes their_score, and with an empty search string every hit has_score0.
If you need a scored query, use the callback from the Search section. The $body parameter is the \ONGR\ElasticsearchDSL\Search object,
so you can add the query with addQuery():
use ONGR\ElasticsearchDSL\Query\FullText\QueryStringQuery; $results = Product::search('', function(\Elastic\Elasticsearch\Client $client, $body) { $body->addQuery(new QueryStringQuery('this AND that', [ 'fields' => ['name', 'email'], ])); return $client->search(['index' => 'products', 'body' => $body->toArray()])->asArray(); })->raw();
Limiting returned fields
Sometimes your indexed models have fields that should not appear in returned result.
You can set returned fields in source option ->options(['source' => ['this', 'that', 'something', 'else']])
Pagination
The engine supports Elasticsearch pagination
with Scout Builder pagination or by setting page sizes
and offsets using the ->take($size) method and ->options(['from' => $from]).
Caution : Builder pagination takes precedence over the
take()andoptions()setting.
For example:
Product::search() ->take(20) ->options([ 'from' => 20, ]) ->paginate(50);
This will return the first 50 results, ignoring the specified offset.
Search amongst multiple models
You can do it with MixedSearch class, just pass indices names separated by commas to the within method.
MixedSearch::search('title:Barcelona or to:Barcelona') ->within(implode(',', [ (new Ticket())->searchableAs(), (new Book())->searchableAs(), ])) ->get();
In this example you will get collection of Ticket and Book models where ticket's arrival city or
book title is Barcelona
Working with results
Sometimes you need more than models in the result. For example, the highlighted text or the score of each hit.
The engine builds the result collection with a HitsIteratorAggregate taken from the container. You can bind your own class there and put anything you want into the result.
The example below adds highlights to the models.
1. Ask Elasticsearch for highlights
Elasticsearch does not send highlights by default. Add a Highlight object to the search body in a callback:
use ONGR\ElasticsearchDSL\Highlight\Highlight; $products = Product::search('zonga', function (\Elastic\Elasticsearch\Client $client, $body) { $highlight = new Highlight(); $highlight->addField('title'); $highlight->addField('description'); $body->addHighlight($highlight); return $client->search([ 'index' => (new Product)->searchableAs(), 'body' => $body->toArray(), ])->asArray(); })->get();
Now every hit in the response has a highlight key. But ->get() returns only models, so this data is lost. The next step keeps it.
2. Write your own HitsIteratorAggregate
namespace App\Search; use ArrayIterator; use Illuminate\Support\Collection; use Laravel\Scout\Builder; use Matchish\ScoutElasticSearch\ElasticSearch\HitsIteratorAggregate; final class HighlightedHitsIteratorAggregate implements HitsIteratorAggregate { private array $results; /** @var callable|null */ private $callback; public function __construct(array $results, ?callable $callback = null) { $this->results = $results; $this->callback = $callback; } public function getIterator(): ArrayIterator { $hits = $this->results['hits']['hits'] ?? []; // MixedSearch can return hits of different classes. // Group them by class and load each group with one query. $models = collect($hits) ->groupBy('_source.__class_name') ->flatMap(function (Collection $classHits, string $class) { $model = new $class; $model->setKeyType('string'); $builder = new Builder($model, ''); // Keep the ->query() callback so eager loading still works. if ($this->callback) { $builder->query($this->callback); } return $model->getScoutModelsByIds($builder, $classHits->pluck('_id')->all()) ->keyBy(function ($model) use ($class) { return $class.'::'.$model->getScoutKey(); }); }); // Sort the models in the same order as the hits // and copy the highlights to each model. $result = collect($hits)->map(function (array $hit) use ($models) { $model = $models->get(($hit['_source']['__class_name'] ?? '').'::'.$hit['_id']); // The document is in the index, but the model is not in the database. if ($model === null) { return null; } $model->highlight = $hit['highlight'] ?? []; return $model; })->filter()->values()->all(); return new ArrayIterator($result); } }
3. Bind the class in a service provider
use Matchish\ScoutElasticSearch\ElasticSearch\HitsIteratorAggregate; use App\Search\HighlightedHitsIteratorAggregate; ... public function register(): void { $this->app->bind(HitsIteratorAggregate::class, HighlightedHitsIteratorAggregate::class); }
4. Read the highlights
foreach ($products as $product) { $product->highlight; // ['title' => ['<em>zonga</em> sneakers']] }
Good to know
- The engine creates your class with
makeWith(['results' => ..., 'callback' => ...]). Do not change the constructor signature. $callbackis the callback from the Scout->query()method. Give it to theBuilder. If you skip this, eager loading with->query()no longer works.$model->highlight = ...creates a normal model attribute. It appears intoArray(), andsave()tries to write ahighlightcolumn. To avoid this, declarepublic array $highlight = [];in your model. Then PHP sets a real property, and Eloquent does not see an attribute.- Aggregations are in
$this->results['aggregations']. You can read them in the same class. If you need only the raw response, call->raw()instead.
🆓 License
Scout ElasticSearch is an open-sourced software licensed under the MIT license.
