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

Elastic Scout Driver Plus Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Aliases are mandatory for zero-downtime. If 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.
  • Mapping changes are not in-place. Elasticsearch can't change a field's type. Changing 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.
  • Nothing is searchable immediately after indexing. The default refresh interval (~1s) means writes aren't visible right away. In tests and demos, call refresh; in production, don't assume read-after-write consistency.
  • Sorting/filtering on 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.
  • Deep pagination hits 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 vs object. Arrays of objects are flattened by default; if you need to correlate fields within an array element (e.g. variants with price and color), map as 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.
  • Three versions have to line up. The Elasticsearch PHP client major must match your cluster (7.x client for 7.x, 8.x for 8.x), and the Plus major must match your Laravel/Scout pair — v7.x is the first line supporting Laravel 13.x / Scout 11.x. A mismatched client produces confusing connection or content-type errors that look like app bugs; a mismatched Plus major surfaces as method-not-found or contract errors during the Scout upgrade.
  • Upgrading Plus is an upgrade of Scout semantics too. When you move to v7, re-check Scout's engine contract and any 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.
  • Queued indexing lags and can reorder. If a user updates a record and immediately searches, they may not see the change; if jobs run out of order, an older document can overwrite a newer one. For critical flows, index synchronously or version documents.
  • Debug by dumping the request body. When a query returns unexpected results, inspect the generated DSL (builder 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.
  • Extension points: the engine, document factory, and query classes are the places to customize — e.g. a custom document factory for multi-model hydration, or a subclassed query for a repeated clause. Prefer composing Query:: calls in app code over forking the package.
  • Config quirks: because Plus registers through the base driver, ensure only one provider is active and 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.
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.
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr
laravel2580/idehelpers
delatbabel/viewpages
anavel/gettext
muhamadrezaar/highcharts
delatbabel/keylists
frankkessler/salesforce-laravel-oauth2-rest
anavel/foundation
wdmtech/enviricons
eii/laravel-captcha
muka/shape-reader
tdt/json