brokerexchange/elasticbuilder
Brokerexchange ElasticBuilder offers a fluent, Laravel-friendly query builder for Elasticsearch, simplifying complex search queries, filters, and aggregations without hand-writing raw JSON while keeping search logic readable and maintainable in PHP.
Install the package alongside the official Elasticsearch PHP client, since the builder produces query DSL arrays that still need a transport to reach the cluster:
composer require brokerexchange/elasticbuilder
composer require elasticsearch/elasticsearch
There is no evidence of a Laravel auto-discovered service provider or facade in the package metadata, so assume it is a plain library you instantiate yourself. Start by reading, in this order:
src/ — identify the entry point (typically something like a Query, Builder, or Search class). That class's public methods are the API surface; there is little documentation to fall back on.tests/ — for a small package this is the most reliable usage documentation. Copy the fixtures; they show exact method names and the array shape each clause emits.composer.json — check the PHP constraint and whether it pulls a specific elasticsearch/elasticsearch major version. This determines ES 7 vs ES 8 compatibility.First use case — a filtered listing query against an index of, say, orders:
use Brokerexchange\ElasticBuilder\...; // confirm actual namespace from src/
$body = (new Query())
->bool()
->filter('status', 'paid')
->must('customer.name', 'match', $term)
->range('created_at', ['gte' => now()->subDays(30)->toIso8601String()])
->getBody();
$response = app(Client::class)->search([
'index' => 'orders',
'body' => $body,
]);
Do not trust those method names verbatim — verify against src/ first. The point of the exercise is to confirm the builder's return type: most builders of this kind return a plain array, which is what makes them easy to slot into existing code.
Wrap the builder, don't sprinkle it. The value of a DSL builder is composition, so keep it behind a query object per search screen rather than calling it inline in controllers:
final class OrderSearch
{
public function build(OrderFilters $filters): array
{
$q = (new Query())->bool();
if ($filters->status) {
$q->filter('status', $filters->status);
}
if ($filters->term) {
$q->must('description', 'multi_match', [
'query' => $filters->term,
'fields' => ['description^2', 'customer.name'],
]);
}
return $q->getBody();
}
}
This keeps the "which clauses are optional" logic in one testable place and gives you a seam to swap the package out later.
Bind it into the container. Register the client and a thin search service in a ScoutServiceProvider-style provider (or your own):
$this->app->singleton(Client::class, fn () => ClientBuilder::create()
->setHosts(config('elastic.hosts'))
->build());
Reuse clause fragments. Build common filters once (tenant isolation, soft-delete exclusion) as arrays or small builder helpers, then merge them into every query. Because the output is an array, this is just PHP array manipulation — no package support required.
Pair with pagination and sorting deliberately. Keep from/size out of the builder if it doesn't model them, and apply them at the call site; this avoids the builder silently overriding your pagination when you merge arrays.
Test by asserting the array, not the cluster. The killer feature of a DSL builder is that you can unit-test query shape without Elasticsearch running:
it('applies a status filter', function () {
$body = (new OrderSearch())->build(new OrderFilters(status: 'paid'));
expect($body)->toMatchArray([
'query' => ['bool' => ['filter' => [['term' => ['status' => 'paid']]]]],
]);
});
Add one or two integration tests against a real (or containerised) cluster for the paths where ES semantics matter — analyzers, minimum_should_match, nested documents.
Consider Laravel Scout for read paths. If most of your search is simple full-text over Eloquent models, Scout plus a custom searchableAs/toSearchableArray may cover 80% of it; reach for this builder for the complex filtered/aggregated screens Scout handles awkwardly.
composer.json — at 11 stars this is not a package you want floating.must vs filter vs should. Putting exact-match filters in must forfeits the filter cache and skews _score. Default to filter for anything non-relevance and only use must for scoring clauses. Builders rarely enforce this; you have to.bool defaults are a trap. With no must/filter, should clauses default to minimum_should_match: 1, but as soon as a must or filter exists they become optional. If your results suddenly widen when you add a filter, this is why — set minimum_should_match explicitly.from + size is capped at index.max_result_window (10k by default). For exports or infinite scroll, use search_after with a stable sort (include a tiebreaker like _id or a unique field).['term' => ['status' => null]] or empty must arrays — ES will error or return nothing. Normalise nulls in your filter DTO before they reach the builder.json_encode($body, JSON_PRETTY_PRINT) before sending. If ES rejects the query, run it through _validate/query?explain=true — the error messages point at the exact clause path, which is far more useful than the exception the transport throws.text field fails unless it has a keyword sub-field; sorting on a missing field needs unmapped_type. These are mapping problems, not builder problems, but they surface at the builder call site.query only. If you need aggs, check before committing; you may end up hand-rolling the aggregation array and merging it in.repositories path override or a fork if it's abandoned.How can I help you explore Laravel packages today?