babenkoivan/elastic-scout-driver-plus
A powerful Elasticsearch driver for Laravel Scout, extending the official elastic-scout-driver with aggregations, suggestions, custom queries, and advanced search features. Integrates cleanly with Eloquent models to bring scalable, feature-rich full-text search to Laravel applications.
Install the package and let Composer pull in the Elastic Scout Driver and the Elasticsearch PHP client:
composer require babenkoivan/elastic-scout-driver-plus
Version pairing matters. The current major (v7.x) is the line that adds Laravel 13.x and Scout 11.x support, on top of the previously supported Laravel/Scout versions. Let Composer resolve it, but if you're on an older Laravel/Scout combination, check the constraint and pin the matching Plus major rather than forcing --ignore-platform-reqs. Upgrading across a Scout major (e.g. 10 → 11) means re-running the app test suite for engine/Searchable contract changes, not just flipping the version.
You need Laravel Scout installed and an Elasticsearch 7.x/8.x cluster reachable from the app. The driver is registered as the usual elastic Scout driver, so configuration lives in config/scout.php (and the base driver's client config):
// config/scout.php
'driver' => env('SCOUT_DRIVER', 'elastic'),
'elastic' => [
'hosts' => [env('ELASTIC_HOST', 'localhost:9200')],
// 'queue' => true, // index via queue workers
],
Make a model searchable with the standard Scout trait, then give it an index name and a document shape:
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
public function searchableAs(): string
{
return 'posts'; // ideally an alias, see below
}
public function toSearchableArray(): array
{
return [
'title' => $this->title,
'body' => $this->body,
'status' => $this->status,
'tags' => $this->tags,
'author_id' => $this->author_id,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
Populate the index and run your first DSL query:
php artisan elastic:index "App\Models\Post"
use Elastic\ScoutDriverPlus\Support\Query;
$result = Post::searchQuery()
->query(
Query::bool()
->must(Query::match()->field('title')->query('laravel'))
->filter(Query::term()->field('status')->value('published'))
)
->size(20)
->execute();
$result->total(); // total hits
$result->hits(); // Hit decorators
$result->models(); // hydrated Eloquent models
Where to look first: the README's Search section, then the source of Elastic\ScoutDriverPlus\Support\Query (the DSL factory), SearchRequestBuilder (what searchQuery() returns), and the SearchResult / Hit decorators. Also skim the base Elastic\ScoutDriver engine, since Plus layers on top of it rather than replacing it. On a fresh v7 install, also glance at the package's changelog/composer constraints so you know which Laravel/Scout versions you're actually running against.
First real use case: a search endpoint where q becomes a multi_match over title/body, plus facet filters (term/terms), a sort on created_at, and a terms aggregation for a filter sidebar. This is exactly the gap between Scout's simple search() and raw Elasticsearch.
Compose bool queries with the Query:: factory. Treat Query::bool() as the root and build must/filter/should/mustNot branches. Put full-text clauses in must and exact filters in filter (filters are cacheable and don't affect score).
$builder = Post::searchQuery()->query(
Query::bool()
->must(Query::multiMatch()->fields(['title^2', 'body'])->query($q))
->filter(Query::terms()->field('tag')->values($tags))
->filter(Query::range()->field('created_at')->gte($since))
);
Wrap common searches in model methods. Return the SearchRequestBuilder so callers can add pagination, sorting, or extra filters without duplicating DSL:
public static function searchPublished(string $q): SearchRequestBuilder
{
return static::searchQuery()->query(/* ... */);
}
Aggregations belong on the builder, not a second request. Add ->aggregate('by_status', 'terms', ['field' => 'status']) (signature depends on version) and read $result->aggregations(); use those to build facet UIs from the same round trip.
Highlights and suggestions are first-class on the builder (->highlight(...), ->suggest(...)) and exposed through Hit/SearchResult accessors. This is the main reason to use Plus over plain Scout: you don't have to drop to the raw client to show highlighted snippets or "did you mean" results.
Paginate without losing the DSL. Use the builder's pagination helper where available (e.g. ->paginate(15)), which returns a Laravel paginator over hydrated models. If you need cursor-style iteration, use search_after with ->sort(...) rather than deep from/size pagination.
Hydrate carefully. hits()->model() gives you the Eloquent model for a hit; $hit->score(), $hit->document(), and $hit->highlight() expose the raw response pieces. Prefer models() when you only need records, and keep the hits collection when you need scores/highlights alongside models.
Multiple models per index. Point several models at the same searchableAs() alias, add a discriminator field (e.g. type => 'post') in toSearchableArray(), and put a term filter on it in queries. Hydration then needs the model class to be inferable from the document (via the discriminator or a custom document factory) — check the package's multi-model section before relying on automatic hydration.
Zero-downtime reindex with aliases. This is the package's standout operational feature. Have searchableAs() return an alias (e.g. posts), point the alias at a concrete index (posts_20240101), and use php artisan elastic:migrate ... to build a new index with current mappings/settings, reindex into it, and atomically swap the alias. Queries keep hitting the alias throughout. Verify the migrate command's exact signature in your installed version.
Manage mappings explicitly. The driver can create indices from toSearchableArray() inference, which is fine for prototypes. For production, define mappings/settings (keyword subfields, date formats, nested types) via the package's index creation command or a small Artisan command of your own that calls the Elasticsearch client, and keep them in version control alongside migrations.
Queue your writes. Enable scout.queue / 'queue' => true and run workers; bulk reindexes should use chunked scout:import or the package's indexing command rather than model-by-model saves. Use Post::withoutSyncingToSearch(fn () => ...) for bulk DB operations that shouldn't touch the index.
Testing. Scout's Scout::fake() only covers the basic engine; Plus's DSL and result decorators need a real (or containerized) Elasticsearch. In tests, index a small fixture set, force a refresh (refresh: true / refresh the index) before asserting, and use a dedicated test index alias per suite.
searchableAs() returns a concrete index name, elastic:migrate can't swap atomically and you risk querying a half-populated index. Decide alias-vs-index on day one; retrofitting is painful.status from text to keyword, or adding a keyword subfield, requires a new index and a reindex — which is exactly what elastic:migrate is for. Plan for it.refresh; in production, don't assume read-after-write consistency.text fields fails or behaves oddly. Use keyword subfields (.keyword) or explicit keyword mappings for filters, terms aggregations, and sorts. This is the #1 source of "my filter returns nothing" bugs.models() can contain nulls or fewer items than hits. Documents can be stale (deleted rows, failed deletes, reindex lag). Always null-check $hit->model() and consider a reconciliation job that deletes orphaned documents.max_result_window (10,000). Beyond that, switch to search_after with a stable sort (including a tiebreaker like id), or use the scroll API for exports.nested and use nested queries — otherwise filters will match across different array elements.search() vs searchQuery(). Scout's Model::search() uses the simple engine path and won't accept the Plus DSL. Use searchQuery() whenever you need filters, aggregations, suggestions, or highlights; mixing the two in one codebase confuses readers.Searchable behaviour you rely on, run the search test suite against a real cluster, and re-verify custom document factories/query subclasses still satisfy the interfaces.toArray()/request body) and replay it in Kibana/Dev Tools. Most "the package is broken" cases are a mapping mismatch or a text vs keyword mistake visible in the raw JSON.Query:: calls in app code over forking the package.scout.driver is still elastic (not a Plus-specific name). Clear config cache after changing hosts/credentials, and confirm the client is hitting the intended cluster before debugging queries.How can I help you explore Laravel packages today?