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

Elasticsearch Query Builder Laravel Package

spatie/elasticsearch-query-builder

A lightweight, fluent PHP API for building and executing ElasticSearch queries. Chain queries and aggregations, then call search(). Built for Spatie's search-string-parser but flexible enough to cover most common use cases.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package alongside the official client:

composer require spatie/elasticsearch-query-builder

Check the version you get. This package tracks the client major: v1 works with elasticsearch/elasticsearch v7, v2 requires the v8 client (Elastic\Elasticsearch\Client). Mixing a v2 builder with a v7 client is the single most common install-time failure.

Everything you touch day-to-day lives in Spatie\ElasticsearchQueryBuilder\Builder. It takes only a client:

use Elastic\Elasticsearch\ClientBuilder;
use Spatie\ElasticsearchQueryBuilder\Builder;
use Spatie\ElasticsearchQueryBuilder\Queries\MatchQuery;
use Spatie\ElasticsearchQueryBuilder\Queries\RangeQuery;

$client = ClientBuilder::create()
    ->setHosts([config('services.elastic.host')])
    ->build();

$results = (new Builder($client))
    ->index('products')
    ->addQuery(MatchQuery::create('name', 'spatie', fuzziness: 3))
    ->addQuery(RangeQuery::create('price')->gte(100)->lte(5000), 'filter')
    ->size(20)
    ->search();

Your first hour should be spent on three things, in this order:

  1. getPayload() — dump the generated DSL before ever hitting the cluster. It is the fastest way to confirm your query is what you think it is.
  2. src/Queries and src/Aggregations — the README lists most of them, but the directory is the source of truth, and each class exposes extra fluent setters that the README only hints at (RangeQuery::gte()->lte(), NestedQuery::innerHits(), BoolQuery::add($query, $occurrence)).
  3. search() — returns the raw Elasticsearch response as an array. There is no model hydration, no paginator, no collection wrapping.

The first real use case is nearly always "search a listing page with a text query plus filters and a facet sidebar" — match query for relevance, filter-occurrence queries for the checkbox filters, TermsAggregation for the sidebar counts. That single screen exercises 80% of the package.

Implementation Patterns

Wrap the builder in a search object, never build inline in a controller. The package is intentionally dumb — it accumulates state and spits out DSL. Keep the domain knowledge in one class:

final class ProductSearch
{
    public function __construct(private Builder $builder) {}

    public function handle(ProductSearchRequest $request): array
    {
        return $this->builder
            ->index('products')
            ->addQuery(MultiMatchQuery::create($request->term, ['name^3', 'description'], fuzziness: 'auto'))
            ->addQuery(TermsQuery::create('brand', $request->brands), 'filter')
            ->addQuery(RangeQuery::create('price')->gte($request->min)->lte($request->max), 'filter')
            ->addAggregation(TermsAggregation::create('brands', 'brand.keyword'))
            ->addSort(Sort::create('_score', Sort::DESC))
            ->from($request->page * 20)
            ->size(20)
            ->search();
    }
}

Bind a fresh Builder per request (a scoped binding in Laravel, or new Builder($client) inside the method). Do not inject a shared singleton — see Gotchas.

Choose occurrence types deliberately. addQuery() defaults to must, which contributes to _score. Anything that is a hard constraint (status, tenant id, date window, price) should be filter: it is cacheable, cheaper, and stops filters from polluting relevance. Use must_not for exclusions and should for boosting alternatives — but remember that once any must/filter clause exists, should clauses become optional boosters only.

Reach for BoolQuery when you need structure. Chained addQuery() calls are a flat bool.must; if you need nested boolean groups (e.g. (A OR B) AND C), build the group explicitly:

BoolQuery::create()
    ->add(MatchQuery::create('name', 'acme'), 'should')
    ->add(MatchQuery::create('brand', 'acme'), 'should')
    ->add(TermsQuery::create('status', ['active']), 'filter');

Treat the response as data. search() gives you ['hits' => ['hits' => [...], 'total' => [...]], 'aggregations' => [...]]. Map hits to your own DTO/collection in the same class so the raw array never leaks upward. $results['hits']['total']['value'] (not ['total']) is the v7+/v8 shape.

Use MultiBuilder for dashboard-style screens that need several independent queries in one round trip, and collapse() when an index has multiple documents per logical entity and you want one row per entity with grouped inner_hits.

Test against the payload, not the cluster. Because getPayload() is deterministic, a plain PHPUnit test with a mock Client (or no client assertion at all) can assert the exact DSL array for every filter combination. This is where the package pays for itself — you get full query coverage in CI with zero Elasticsearch infrastructure.

Gotchas and Tips

  • The builder is stateful. Every addQuery() / addAggregation() mutates the instance. Reusing one instance across two searches — or across jobs in a queue worker — silently doubles your clauses. Always construct fresh.
  • search() returns an array; failures throw. The builder does not catch or normalise client exceptions. A missing index throws a 404 exception, a bad DSL throws a 400 with the parse reason. Catch broadly at the boundary and log getPayload() alongside the error — it turns "search is broken" into a five-minute fix.
  • Elasticsearch will not validate your DSL for you. Passing an unknown field name, a term against an analysed field, or fuzziness: 3 to a keyword field produces either zero hits or a shard failure. Verify mappings with GET index/_mapping before blaming the builder.
  • should-only queries and minimum_should_match. If you add only should clauses, ES defaults to minimum_should_match: 1 — fine. The moment you mix in a must/filter, that default flips to 0 and your "or" fallback silently returns everything. The builder has no minimum_should_match() helper; if you need it, build the BoolQuery yourself and add the parameter via a custom query class.
  • Docs inconsistencies to be aware of: the README alternates between GeoshapeQuery and GeoShapeQuery, and between MatchQuery::create(...) and new MatchQuery(...). Prefer ::create() consistently and confirm the class name against src/Queries — PHP class names are case-insensitive at the language level but autoloaders and static analysis are not always forgiving.
  • Deep pagination. from()/size() are just pass-throughs; the 10 000-document max_result_window still applies. For exports, implement search_after by hand or use the index API directly — there is no scroll helper here.
  • No refresh handling. Writes via your own index calls are not visible immediately. In tests that index-then-search, pass refresh: true on the index call, not on the builder.
  • Facets need size(0) if you only want aggregation counts — otherwise you pay for hits you throw away.
  • Extension point: every query and aggregation is just a small class exposing toArray(). When you hit an unsupported DSL feature (script queries, function_score, minimum_should_match), write your own class implementing the package's query contract and pass it to addQuery() — you do not need to fork. This is the intended escape hatch and it keeps your custom DSL in one discoverable place.
  • Do not expect Laravel integration. There is no config file, facade, or Scout driver. You own client construction, index management, and result mapping. Budget for that; it is roughly a service provider plus two or three classes.
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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin