Laravel Just Got Eloquent-Style Queries for Elasticsearch. I Tried Replacing My Raw DSL With It

How Elastic Bridge brings familiar query-builder syntax to Elasticsearch and OpenSearch — and where it still falls short of hand-written queries for complex AI search.


A hotel search endpoint, the version most Laravel-plus-Elasticsearch codebases actually have: a PHP array shaped like Elasticsearch’s JSON DSL, nested three or four levels deep, built by hand, with a bool key containing a must array containing a match containing a field name as a string key — the kind of structure where a misplaced bracket doesn’t throw a PHP error, it throws a 400 from the cluster with an error message that requires actually knowing the DSL to decode. Elastic Bridge, a new package from Agyenim Boateng covered on Laravel News this week, replaces that array with HotelRoom::asBoolean()->mustMatch('city', 'accra')->filterByTerm('code', 'usd')->get() — Eloquent’s fluent chain, applied to a search cluster instead of a SQL database. I pulled it into a project that’s been hand-rolling Elasticsearch DSL arrays for a while to see what it actually replaces cleanly, and where the raw array is still the honest answer.

Worth stating plainly before anything else: this is a brand-new package, published this week, with a correspondingly small track record. That’s not a knock — every mature package started exactly here — but it’s a materially different trust level than a tool with years of production mileage, and it shapes the verdict below more than any single API decision does.


What It Actually Replaces — the Common Case

// The raw DSL version — this is what most Laravel + Elasticsearch
// codebases actually have, hand-built, JSON-shaped as a PHP array
$response = $client->search([
    'index' => 'hotel-rooms',
    'body' => [
        'query' => [
            'bool' => [
                'must' => [
                    ['match' => ['city' => 'accra']],
                ],
                'filter' => [
                    ['term' => ['code' => 'usd']],
                    ['range' => ['price' => ['lte' => 500]]],
                ],
            ],
        ],
        'sort' => [['price' => 'asc']],
    ],
]);
// Elastic Bridge — a "bridge" class defines the index,
// the query reads like an Eloquent chain
namespace App\Bridges;

use Lacasera\ElasticBridge\ElasticBridge;

class HotelRoom extends ElasticBridge
{
    protected $index = 'hotel-rooms';
}
$rooms = HotelRoom::asBoolean()
    ->mustMatch('city', 'accra')
    ->filterByTerm('code', 'usd')
    ->filterByRange('price', 500, 'lte')
    ->orderBy('price', 'ASC')
    ->cursorPaginate(15)
    ->get(['name', 'price', 'code']);

This is a genuine, meaningful improvement for exactly the class of query it targets — combining a full-text match with structured filters and a sort, which is the overwhelming majority of what a typical e-commerce or listings search actually needs. The nested-array DSL’s real cost was never that it’s impossible to write correctly; it’s that it’s tedious to write correctly and silent about it when written wrong, since a subtly malformed query structure often still executes, just not the way you intended. A fluent chain that mirrors Eloquent’s existing where()-style syntax removes an entire category of “I nested this bracket one level too deep” mistakes, the same way Eloquent’s query builder removed the equivalent class of raw-SQL-string mistakes over a decade ago.

The distinction between mustMatch() and filterByTerm() is worth understanding, not just memorizing — it maps directly to Elasticsearch’s own query structure rather than being an arbitrary naming choice. A match clause is analyzed and scored, affecting relevance ranking; a term filter is an exact, unscored match used purely to narrow results. Elastic Bridge keeping that distinction visible in the method names, rather than collapsing both into one generic where(), is the right call — hiding that distinction would make the fluent API easier to learn and worse at representing what’s actually happening at query time.


Multi-Field Search and Aggregations — Where the Fluent API Earns Its Keep

// Searching across multiple fields for one term
$rooms = HotelRoom::multiMatch(
    field: ['advertiser', 'service_type'],
    query: 'hotel',
)->get();
// Aggregations attached directly to a document query,
// with the result surfaced as a property on the returned collection
$rooms = HotelRoom::asBoolean()
    ->mustMatch('city', 'accra')
    ->withAggregate('avg', 'price')
    ->get();

$averagePrice = $rooms->priceAvg(); // dynamically available on the
                                      // collection instance itself

The aggregation ergonomics are the part I didn’t expect to like as much as I did. Raw DSL aggregations require reading the response’s aggregations key separately from hits, correlating the two by hand. Having priceAvg() available directly on the same collection the documents came back in — and, per the package’s own documentation, scoped correctly to that specific result set even across long-lived worker processes handling multiple concurrent searches — removes a category of bug that’s easy to introduce in a raw-DSL codebase: accidentally reading a stale or mismatched aggregation result because the document response and the aggregation response got separated somewhere in the call stack.


Testing Without a Cluster — the Part That Changes Daily Workflow Most

public function test_builds_currency_filter(): void
{
    HotelRoom::fake([
        'hits' => ['total' => ['value' => 0, 'relation' => 'eq'], 'hits' => []],
    ]);

    $query = HotelRoom::asBoolean()
        ->filterByTerm('code', 'usd')
        ->toQuery();

    $this->assertSame([
        'query' => ['bool' => ['filter' => [['term' => ['code' => 'usd']]]]],
    ], $query);
}

This is a genuinely bigger deal than it looks, for anyone who’s tried to write a real test suite against raw Elasticsearch DSL before. toQuery() lets a test assert on the exact shape of the generated query without a running cluster, and fake() gives back a controllable response for testing what the application does with results, also without a cluster. The realistic alternative in most raw-DSL codebases is either standing up a real Elasticsearch container for CI (slow, and a genuine operational dependency for every test run) or skipping search-layer tests almost entirely and hoping manual QA catches a malformed query before production does. Being able to assert “this specific chain of fluent calls produces this specific DSL structure” as a fast, unit-level test is the single feature in this package I’d most want to keep even if I decided not to adopt the rest of the fluent query API.


Where It Falls Short — Complex AI and Semantic Search

This is the honest limitation, and it’s the one worth knowing before reaching for this package specifically because a project needs AI-powered search. Nothing in Elastic Bridge’s documented API covers vector or semantic search — no knn() method, no dense-vector query helper, no equivalent to the script_score or knn query types Elasticsearch and OpenSearch both support natively for embedding-based similarity search. The fluent API’s entire vocabulary — mustMatch, filterByTerm, filterByRange, multiMatch — is built around Elasticsearch’s classic, text-and-structured-filter query model, the same model the package’s own examples demonstrate throughout.

// What Elastic Bridge's fluent API does not have a method for —
// a kNN query against a dense_vector field, hand-written DSL,
// escape-hatched through the package however it exposes raw query access
$response = $client->search([
    'index' => 'hotel-rooms',
    'body' => [
        'knn' => [
            'field' => 'description_embedding',
            'query_vector' => $queryEmbedding,
            'k' => 10,
            'num_candidates' => 100,
        ],
    ],
]);

For a project doing genuine semantic search on top of Elasticsearch or OpenSearch — the embedding-based, “find conceptually similar results” pattern covered in earlier posts on this blog — the fluent API covers the traditional keyword-and-filter half of a hybrid search implementation cleanly, and the vector half still needs either raw DSL dropped in directly, or waiting for (or contributing) a future version of the package that adds knn() as a first-class method the way multiMatch() already exists for text. This isn’t a defect in what shipped — a brand-new package covering the classic query model well before tackling vector search is a completely reasonable scope decision — but it means the subtitle’s promise needs a caveat: this replaces raw DSL for structured and full-text search convincingly. It does not yet replace raw DSL for the AI-search half of a modern hybrid search stack.


The Honest Verdict, Given How New This Is

For the classic search case — full-text matching, structured filters, sorting, pagination, aggregations — this is a real, adoptable improvement over hand-written DSL arrays, with the same category of benefit Eloquent itself provides over raw SQL: fewer structural mistakes, more readable diffs in code review, and a testing story (fake()/toQuery()) that’s genuinely better than what most raw-DSL codebases have today. The mustMatch() vs filterByTerm() distinction staying visible rather than papered over is a good sign of a package built by someone who understands the underlying query model, not just wrapping it superficially.

For anything involving vector or hybrid search, it’s not there yet, and a project whose search feature is primarily AI-powered similarity search should treat this as a package to watch rather than adopt for that specific piece today — the raw client, or a purpose-built vector search approach, remains the honest answer for that half of the problem.

And because it’s brand new, the appropriate rollout for a production codebase is the same as for any pre-1.0-track package: adopt it for a new, lower-stakes search feature first, watch how quickly issues get triaged and fixed, and let the package earn its way into a critical search path rather than migrating an existing production query layer onto it in one pass on week one.


The One Rule

A fluent API replacing a hand-built DSL array is worth adopting exactly to the degree it covers the query patterns a project actually uses, and worth treating with real caution exactly to the degree a project’s needs sit outside that coverage — which is precisely the situation here: genuinely good for the classic keyword-and-filter search most Laravel apps actually run, genuinely absent for the vector search a growing number of them are adding on top of it. The right read on a new package like this isn’t “adopt it everywhere” or “wait for 1.0 to even look” — it’s mapping its documented method list against your own actual query patterns, honestly, before deciding which parts of your raw DSL it’s actually ready to replace today.

Leave a Reply

Your email address will not be published. Required fields are marked *