erichard/elasticsearch-query-builder
A fluent PHP query builder for Elasticsearch that lets you construct complex search queries through an elegant, chainable API instead of hand-writing JSON. A good fit for Laravel and standalone PHP projects that need readable, maintainable Elasticsearch DSL.
Installation
composer require erichard/elasticsearch-query-builder
You also need an Elasticsearch PHP client (elasticsearch/elasticsearch) since this package only builds queries — it does not ship a transport. In a Laravel app the client is usually bound in a service provider or resolved via Elastic\Elasticsearch\ClientBuilder::fromConfig(config('elasticsearch')).
Where to look first
README.md — the method-by-method examples are the fastest way to learn the fluent surface.src/QueryBuilder.php — the entry point (where, index, size, from, sort, get, first, count, search, toArray).src/Query/ and src/Aggregation/ — the DSL nodes the builder assembles; useful when you need to build something the fluent API does not expose.src/Clients/ (or wherever the client wrapper lives) — this is where the actual search() / index() / delete() calls are dispatched.First use case
Replace a hand-rolled DSL array with a chained builder:
use Erichard\ElasticQueryBuilder\QueryBuilder;
$builder = new QueryBuilder($client);
$builder->setIndex('posts');
$builder->where('status', 'published');
$builder->setSize(20);
$response = $builder->get(); // returns the raw ES response
$hits = $response['hits']['hits'];
The win is composition: conditionally adding where, addSort, or aggregations without building a nested bool array by hand.
Repository / service wrapper
Do not scatter new QueryBuilder(...) across controllers. Wrap it in a repository class so the index name, client, and default filters live in one place:
class PostSearchRepository
{
public function __construct(private \Elastic\Elasticsearch\Client $client) {}
public function search(array $filters, int $page = 1, int $perPage = 20): array
{
$qb = new QueryBuilder($this->client);
$qb->setIndex('posts');
$qb->setFrom(($page - 1) * $perPage);
$qb->setSize($perPage);
if (!empty($filters['status'])) {
$qb->where('status', $filters['status']);
}
if (!empty($filters['author_id'])) {
$qb->where('author_id', $filters['author_id']);
}
if (!empty($filters['q'])) {
$qb->where('title', $filters['q']); // or a match/multi_match node
}
$qb->addSort('published_at', 'desc');
return $qb->get();
}
}
Bind it in AppServiceProvider, inject it into controllers or jobs. This keeps the "index name string" out of application code and lets you swap indices per environment.
Composing conditional clauses
The fluent style shines when filters are optional. A small pattern is to build the base builder, then apply a list of closures:
$builder = new QueryBuilder($client);
$builder->setIndex('products');
collect($filters)->each(function ($value, $field) use ($builder) {
if ($value !== null) {
$builder->where($field, $value);
}
});
Prefer explicit if blocks for readability and to make the mapping from request -> ES field obvious.
Pagination as a first-class concern
Treat from/size as your pagination contract. Because ES pagination is offset-based, mirror it in the response shape: return total from $response['hits']['total']['value'] and compute page counts manually. For deep pagination prefer search_after via a raw body rather than large from values.
Aggregations alongside hits
Use the builder's aggregation helpers and attach them to the same query:
$qb->addAggregation('by_status', /* terms aggregation */);
$response = $qb->get();
$buckets = $response['aggregations']['by_status']['buckets'] ?? [];
Escape hatch for unsupported DSL
For anything the fluent API does not cover (function_score, custom analyzers, script_score, nested search_after), either drop into the underlying aggregation/query node classes in src/Query/ or set the raw body. Keep a documented "raw" path in your repository so you are not blocked by the library's surface area.
Index and document lifecycle
Use the builder/clients for index, bulk, delete, and refresh calls in seeders, artisan commands, and queue jobs. Model observers (saved, deleted) that enqueue a job calling the repository are the idiomatic Laravel integration — never index synchronously in a request cycle.
->get() returning typed objects, no relationship loading. You get back raw ES arrays. Write a small toDto() mapper per hit; do not leak raw _source/_id into controllers.elasticsearch/elasticsearch 7.x vs 8.x) and pin it in composer.json. Mismatched client versions are the most common install failure; ES 8's stricter headers and product checks are the usual culprit.get() is a network call, toArray() is not. When debugging, inspect $builder->toArray() (or the equivalent DSL accessor) to see the exact body before it hits the cluster. Log this on failures — raw transport exceptions rarely show the body.refresh on the index (or use refresh: 'wait_for' on writes) in test setup.where semantics. In many versions of this package where maps to a term query, not a full-text match. A term against an analyzed text field will not match multi-word input the way you expect. Be explicit: use match/multi_match nodes for search, term/terms for keyword fields. Know whether your field is text or keyword.?explain or _validate/query while debugging.first() is convenience, not correctness. It sets size 1 and returns one hit; there is no ordering guarantee unless you sorted. Add an explicit sort for deterministic "latest" behaviour.hits.total as an object (value, relation) rather than an integer. Guard for both if you support mixed cluster versions.from/size, and timeouts tend to be hard-coded in examples. Centralise them in config/elasticsearch.php and pass them into the repository so staging/production indices differ cleanly.QueryBuilder and adds your custom nodes from src/Query/. Subclassing QueryBuilder is possible but couples you to internal method signatures across upgrades — prefer wrappers.toArray(), (2) run it via curl/Kibana Dev Tools, (3) check field mapping (GET index/_mapping), (4) check refresh, (5) check index name. That sequence resolves most "the builder is broken" reports.How can I help you explore Laravel packages today?