handcraftedinthealps/elasticsearch-dsl
A fork of ongr/elasticsearch-dsl for building Elasticsearch queries in PHP with a fluent DSL. It extends support to a wider range of Symfony (3.4 through 7.x) and Elasticsearch versions, and powers the handcraftedinthealps Elasticsearch bundle.
This package builds Elasticsearch request bodies as PHP objects. It does not ship a client, a connection pool, retries, or any Laravel service provider. You pair it with elasticsearch/elasticsearch and hand it $search->toArray().
1. Install a branch that matches your cluster major version, together with the official client.
# Elasticsearch 8 cluster
composer require handcraftedinthealps/elasticsearch-dsl:^8.3 elasticsearch/elasticsearch:^8.0
# Elasticsearch 7 cluster
composer require handcraftedinthealps/elasticsearch-dsl:^7.5 elasticsearch/elasticsearch:^7.17
The package version and the client version must move in lockstep; the DSL emits request bodies that the client serializes, so a 7.x DSL against an 8.x cluster is a migration hazard, not a convenience.
2. Read these spots first, in order.
docs/index.md in the repo — the upstream ongr/elasticsearch-dsl documentation applies almost verbatim; the fork only changes compatibility plumbing.src/Query/ and src/Aggregation/ — the class name is the ES query/aggregation name. TermQuery, TermsQuery, BoolQuery, NestedQuery, TermsAggregation, NestedAggregation.tests/ — the fastest way to see the exact array shape each class produces. getArrayCopy() output is your contract.8.2.0 (knn search), 8.3.0 (runtime fields, parent aggregations), and 8.0.2/7.3.2 (trackTotalHits int fix). Those are the features you will actually be asked for.3. Wire the client once, as a singleton. There is no auto-discovery.
// config/elasticsearch.php
return [
'hosts' => [env('ELASTICSEARCH_HOST', 'localhost:9200')],
];
// app/Providers/ElasticsearchServiceProvider.php
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\ClientBuilder;
public function register(): void
{
$this->app->singleton(Client::class, fn () => ClientBuilder::create()
->setHosts(config('elasticsearch.hosts'))
->build());
}
4. First use case: a catalog search endpoint. Build the body, pass it to the client, return the raw response or a resource.
use ONGR\ElasticsearchDSL\Search;
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery;
use ONGR\ElasticsearchDSL\Query\FullText\MatchQuery;
use ONGR\ElasticsearchDSL\Aggregation\Bucketing\TermsAggregation;
$search = new Search();
$search->setSize(20)->setFrom(0)->setTrackTotalHits(true);
$bool = new BoolQuery();
$bool->add(new MatchQuery('title', $term, ['operator' => 'and']), BoolQuery::MUST);
$bool->add(new TermQuery('status', 'published'), BoolQuery::FILTER);
$search->addQuery($bool);
$search->addAggregation(new TermsAggregation('by_brand', 'brand.raw', ['size' => 20]));
$response = app(Client::class)->search([
'index' => 'products',
'body' => $search->toArray(),
]);
That is the whole integration surface: new Search() → addQuery/addAggregation/addSort → toArray() → $client->search().
Repository-per-index, not queries-in-controllers. Keep the DSL in one layer so the rest of the app never imports ONGR\ElasticsearchDSL. When the fork stalls or you move to a different builder, you swap one file.
final class ProductSearchRepository
{
public function __construct(private Client $client) {}
public function search(ProductFilters $filters): array
{
$search = new Search();
$search->setSize($filters->perPage)
->setFrom(($filters->page - 1) * $filters->perPage)
->addQuery($this->buildQuery($filters));
return $this->client->search([
'index' => 'products',
'body' => $search->toArray(),
])->asArray();
}
}
Request → filter fragments, joined with BoolQuery::FILTER. This is the pattern you will use every day: filters in FILTER context (cacheable, non-scoring), free text in MUST.
private function buildQuery(ProductFilters $f): BoolQuery
{
$bool = new BoolQuery();
if ($f->term !== null) {
$bool->add(new MatchQuery('title', $f->term), BoolQuery::MUST);
}
foreach ($f->brands as $brand) {
$bool->add(new TermQuery('brand.raw', $brand), BoolQuery::FILTER);
}
if ($f->minPrice !== null || $f->maxPrice !== null) {
$bool->add(new RangeQuery('price', array_filter([
'gte' => $f->minPrice,
'lte' => $f->maxPrice,
])), BoolQuery::FILTER);
}
return $bool;
}
Guard the empty case: a BoolQuery with no clauses serializes to match_all, which is usually what you want for a browse page but is a silent bug if you meant "no results".
Facets alongside hits in the same request. One round trip; remember setSize(0) when the endpoint exists only for counts.
$search->setSize(0);
$search->addAggregation(new TermsAggregation('by_brand', 'brand.raw', ['size' => 50]));
$search->addAggregation(new TermsAggregation('by_size', 'variants.size', ['size' => 20]));
For multi-valued nested fields, wrap with NestedAggregation + FilterAggregation; a bare TermsAggregation on a nested field returns zero buckets, which is the single most common aggregation bug with this library.
Reusable query fragments as small factories/return types. Since every class returns a plain array via getArrayCopy(), composition is trivial:
final class PublishedFilter
{
public static function create(): TermQuery
{
return new TermQuery('status', 'published');
}
}
Integration with Laravel Scout. Scout owns the client and the index lifecycle; the DSL only helps if you take over query construction. Two viable options:
$search->toArray() inside Engine::search(). You gain Scout's fluent entry point but own pagination and raw response handling.There is no package-provided Scout driver, no service provider, no facade. Do not expect Search::query() to return a DSL object.
Testing is where the DSL pays off. Two layers:
// Unit: assert the query body shape, no cluster needed.
public function test_price_filter_is_in_filter_context(): void
{
$body = (new ProductSearchRepository(...))->buildBody($filters);
$this->assertSame(
[['term' => ['brand.raw' => 'acme']]],
$body['query']['bool']['filter']
);
}
// Integration: run it against a real ES in CI (Docker service).
$this->client->index(['index' => 'products', 'id' => 1, 'body' => ['brand' => 'acme', 'status' => 'published']]);
$this->client->indices()->refresh(['index' => 'products']);
Snapshot tests against toArray() catch accidental structure drift on upgrade far better than reading release notes.
Use the newer features directly if the roadmap asks for them. 8.2.0 added knn search and 8.3.0 added runtime fields and parent aggregations. Runtime fields in particular let you add computed fields without a reindex — a very fast win for a Laravel app that cannot afford a mapping migration.
The README matrix is stale. It claims 7.x targets ^7.0 and stops at Symfony 5/4/3.4. The release notes tell the real story: 8.x supports Elasticsearch 8, 8.1.0 and 7.4.0 add Symfony 7, and 2.8 was dropped. Trust the release tags, not the compatibility table, and pin an exact minor (7.5.*, 8.3.*) so a transitive bump cannot change query output under you.
toArray() returns the body, not a request. Do not json_encode it before passing to the client — the client encodes. Pass 'body' => $search->toArray(), and remember toArray() produces query, aggs, sort, from, size, track_total_hits keys only when set.
Sorting on nested fields needs extra params. addSort('variants.price', 'asc', ['nested' => ['path' => 'variants'], 'mode' => 'min']) — a plain addSort('variants.price') throws a mapping error at query time on nested fields. The DSL will happily build the wrong shape; ES is the only thing that validates it.
trackTotalHits typing. Before 8.0.2 / 7.3.2, trackTotalHits could be coerced incorrectly. If you rely on total counts for pagination, pin at or above those releases.
Aggregation buckets are nested arrays. $response['aggregations']['by_brand']['buckets'] — if by_brand is missing you either registered the aggregation under the wrong name or the field is not .keyword/.raw. Enable ?explain on _validate/query first; a term query against an analyzed text field silently returns nothing.
Query vs. filter context is not enforced. addQuery($termQuery, BoolQuery::FILTER) works, but passing a MatchQuery into FILTER disables scoring and gives you surprising relevance. Keep free text in MUST, everything else in FILTER.
No framework wiring means no safety net. No service provider, no config validation, no profiler integration, no Laravel error handling. Add a _validate/query?explain=true call in local/dev, and log the body at debug level — it is the single most useful debugging step on a Laravel team, because a broken DSL query fails inside $client->search() with an ES parse error, far from the code that built it.
Empty bool and empty should behave differently than you expect. An empty BoolQuery becomes match_all. A should clause with minimum_should_match omitted defaults to ES's rules and may return unexpectedly broad results. Set minimum_should_match explicitly whenever you build an OR group from user input.
PHP and Symfony version floor matters more than the description suggests. 8.1.0/7.4.0 dropped Symfony 2.8. If you are on a modern Laravel (Symfony 7 components), you need ^8.1 or ^7.4; anything older will not resolve.
Low adoption is a real risk, and the mitigation is architectural, not procedural. 21 stars and zero dependents means you are the maintainer of last resort. Keep the DSL behind an interface (ProductSearchRepository, SearchRequestBuilder), snapshot-test toArray(), and be prepared to fork or swap. Never let ONGR\ElasticsearchDSL types leak into controllers, models, or domain services.
Extension points are the classes themselves. Every query and aggregation type is a class you can subclass and register. Search accepts any AbstractQuery/AbstractAggregation, so if a newer ES feature is missing, add a thin class that returns the correct getArrayCopy() shape rather than dropping to raw arrays inline — you keep testability and reuse for free.
How can I help you explore Laravel packages today?