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 Search String Parser Laravel Package

spatie/elasticsearch-search-string-parser

Convert custom search strings like "foo bar status:active @john.doe" into ElasticSearch requests. Add directives via regex and the spatie/elasticsearch-query-builder, with grouping and autocomplete suggestions.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require spatie/elasticsearch-search-string-parser

Pin the major version against your cluster before you write a single directive. ^2.0 requires Elasticsearch 8 and the elasticsearch/elasticsearch 8.x PHP client; the 1.x line is for ES7 clusters. A mismatch surfaces as a transport/client exception, not as a parser error, so verify the cluster version in config/elasticsearch.php (or wherever your client config lives) and constrain Composer accordingly. Also note 2.0 modernised the toolchain to PHP 8.5 / Pest 4 / PHPStan level 5 — if your app is on PHP 8.2 you'll want to check the actual composer.json constraint rather than assume.

You are adopting two packages. The parser is a string→directive dispatcher; every clause you build inside a directive goes through spatie/elasticsearch-query-builder. Read both READMEs together, or you'll spend an afternoon guessing at clause shapes. The parser's own docs are thin — the "How it works: directives" section is the only load-bearing part.

Ignore the ## Usage block in the README. It's an unedited Spatie skeleton stub (echoPhrase('Hello, Spatie!')) that has nothing to do with this package. Go straight to src/ and read the abstract base/pattern directive classes: the methods you must implement (the regex accessor, the apply hook that receives the query builder, and the suggestion method) are not documented anywhere else, and with 51 stars and zero dependents you are the QA.

First use case: one internal search bar, not a public syntax. Pick a screen where users already think in key:value — an admin order list, a support console, an internal CRM — and ship exactly one base directive plus one pattern directive.

// app/Search/OrderSearch.php
final class OrderSearch
{
    public function __construct(private readonly Client $client) {}

    public function run(string $query): SearchResults
    {
        return SearchQuery::forClient($this->client)
            ->baseDirective(new SubjectDirective())      // fuzzy multi_match on the remainder
            ->patternDirectives(
                new StatusDirective(),                   // status:active
                new CustomerDirective(),                 // @acme
            )
            ->search($query);
    }
}

Bind the ES client as a singleton in a service provider, then hydrate the hits back through Eloquent while preserving ES relevance order:

$results = app(OrderSearch::class)->run($request->string('q'));
$ids = $results->hits()->pluck('_id');

return OrderResource::collection(
    Order::whereIn('id', $ids)->get()
        ->sortBy(fn (Order $o) => $ids->search($o->id))
        ->values()
);

That one screen will surface the real questions — greedy regex behaviour, empty remainders, hydration ordering, pagination — at a fraction of the cost of discovering them behind a public feature flag.

Implementation Patterns

One directive class per user-facing concept. status: is a filter, sort: is result control, @user is a mention, group_by: is an aggregation. Keep the regex, the apply logic, and the suggestion text in a single class so that changing status: is a one-file diff. A roadmap ticket should map almost one-to-one onto a directive.

Register pattern directives most-specific → least-specific. The parser iterates directives in registration order, applies a match, strips it from the string, and moves on. A greedy early pattern will eat text intended for a later directive. If you find yourself ordering directives defensively rather than semantically, that's a sign your regexes are too broad — tighten them to ([^\s]+) rather than (.*).

Treat the base directive as the fallback, and guard the empty case. Once all pattern directives have stripped their matches, whatever remains is handed to the single base directive. For a query like status:active the remainder is an empty string — your base directive must no-op rather than emit a match on "", which ES will happily accept and return garbage for.

Add an always-on scoping directive for tenancy and permissions. This is the pattern that matters most day-to-day: a directive that the user never types but that always contributes a term filter on tenant_id or an ACL field. Wire it into the same builder chain so that no code path can construct a SearchQuery without it — otherwise a crafted string is an authorisation bypass.

Use suggestions as a separate, cheap endpoint. Autocomplete is the reason this package is worth adopting over hand-rolled parsing: users discover the company: and @user syntax as they type. Debounce on the client (250–300ms), cap the returned suggestions, and don't recompute aggregations just to render a dropdown.

Build the query once, execute where you need it. Keep directive construction in a dedicated class (OrderSearch, TicketSearch) and call ->search() from the controller. That gives you a seam for tests: assert the generated request body without touching a cluster.

Test directives by asserting the query array, not the response. Your test suite shouldn't need a live Elasticsearch for the parsing layer. Feed a string in, capture the builder, assert the expected query/sort/aggs structure. Reserve integration tests for one or two happy-path searches against a real index — and use a fixture index with a mapping you control.

Grouping is the escape hatch for analytics. group_by:project gives users ad-hoc faceting from the same input box. Implement it as a directive that adds an aggregation rather than a filter, and remember the result shape differs from a plain hits response — your hydration code needs a branch for it.

Gotchas and Tips

Regex greed is the number one source of bugs. (.*) in a pattern consumes the rest of the string, including other directives. Use ([^\s]+) for single-token values, and ("[^"]+"|\S+) if you want quoted multi-word values like company:"aperture science". Test each directive in isolation for every adjacent-directive combination you support.

Pattern order is execution order, and stripping is destructive. Debug by dumping the remaining string after each directive — a directive that "isn't matching" is often a directive whose input was already eaten by an earlier, broader pattern. There is a beforeApply hook (added in 1.1.0) that's the right place for cross-cutting concerns like logging the raw match or rejecting a value that fails validation.

Don't let directive values reach the DSL unvalidated. The package parses; it doesn't sanitise. A status: directive that interpolates straight into a term is fine, but a sort: or range: directive built from user text can produce malformed or expensive queries. Whitelist allowed field names and directions inside the directive rather than trusting the match group.

Field names and mappings are the silent failure mode. A directive that queries company_name against an index where the field is company.name returns zero hits with no error anywhere. When a directive "works in tests but not in production," check the mapping before you check the regex — GET /your-index/_mapping and compare.

Pagination and totals need explicit handling. ES8's PHP client returns total hits differently depending on track_total_hits. If your UI shows "1,234 results," set track_total_hits: true in the base query and read the total from the response rather than from hits()->count().

The ES7/ES8 split bites at the client layer. If you see "no handler found for uri [/_search]" style errors or unexpected response object shapes after upgrading Laravel, check whether Composer pulled the 1.x parser alongside an 8.x client (or vice versa). Pin both the parser major and the elasticsearch/elasticsearch version.

Pin exact versions and read the source on upgrade. Zero dependents means no ecosystem pressure testing the upgrade path for you. Between the beforeApply addition (1.1.0), suggestion metadata (1.2.0), and the 2.0 tooling overhaul, directive signatures have moved. Diff src/ on every bump rather than trusting the changelog summaries.

Laravel-version support is real but narrow. 1.2.1→1.2.3 tracked Laravel 9 through 12, and 1.3.0 added Laravel 13. If you're on an older framework, expect to hold the package back.

Extension points worth knowing now: the beforeApply hook for validation/logging, suggestion metadata for enriching autocomplete payloads (icons, descriptions, result counts), and grouping for aggregations. Everything else you want — rate limiting, permission checks, caching — belongs in your own wrapper service, not inside a directive.

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.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk