gdbots/query-parser
A PHP library for parsing query strings into a structured AST, with support for boolean operators, fielded terms, grouping, ranges, and wildcards. Useful for validating and translating user search input into database queries.
Install with Composer (no service provider, no config file — this is a plain library):
composer require gdbots/query-parser
Then verify what you actually got before writing code against it, because the project was dormant from late 2021 until this release and the maintenance gap means the API around nodes/enums changed:
composer show gdbots/query-parser # version, PHP constraint, deps
php -r "var_dump((new Gdbots\QueryParser\QueryParser())->parse('urgent #billing @alice \"past due\"'));"
v3.0.0 requires PHP 8.1. If you are still on 8.0 or 7.4 you must upgrade before adopting. The second breaking change is the migration from the package's home-grown "versions" to native PHP enums — this is the one that will bite your existing code. Anywhere you were receiving a scalar or a getValue()-style object from the parsed nodes, confirm whether it is now a \BackedEnum case.
Read these files first, in order:
README.md — the canonical example and supported syntax. (Double-check it against src/; it may lag the v3.0.0 refactor.)src/QueryParser.php — the single public entry point (parse()).src/ParsedQuery.php — the value object you will actually live in (getTerms(), getPhrases(), getHashtags(), getMentions(), has*() helpers).src/Node/ — the node types (Term, Phrase, Hashtag, Mention) if you need a visitor. Check whether each node's value accessor now returns an enum case.First use case: a global search box on an admin listing that accepts social-style syntax — status:open #vip @alice "renewal failed" — and maps each token class onto a different column/filter instead of dumping the whole string into one LIKE.
Start by binding it once in a service provider so you are not new-ing it in controllers:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Gdbots\QueryParser\QueryParser::class);
Parse at the edge, pass the parsed object inward. Do the parse in a FormRequest or a small middleware, throw a ValidationException on bad syntax, and hand the ParsedQuery (or your own DTO) to the controller. Downstream code should never see the raw string.
final class SearchRequest extends FormRequest
{
public function rules(): array
{
return ['q' => ['nullable', 'string', 'max:512']];
}
public function parsed(): ParsedQuery
{
try {
return app(QueryParser::class)->parse(trim($this->input('q', '')));
} catch (\Throwable $e) {
throw ValidationException::withMessages(['q' => 'Invalid search syntax.']);
}
}
}
Split structured tokens from free text. The whole value of this package is that #tag and @person are not full-text terms. Treat them as filters and everything else as scored relevance.
Normalise node values to scalars at the boundary — this is essential in v3.0.0. With the enum migration, the same logical token can come back as a string, a backed-enum case, or a legacy object depending on the node type. Do the unwrapping in one place and never let an enum case leak into Eloquent or Elasticsearch:
$parsed = $request->parsed();
$hashtags = array_map($this->value(...), $parsed->getHashtags());
$mentions = array_map($this->value(...), $parsed->getMentions());
$phrases = array_map($this->value(...), $parsed->getPhrases());
$terms = array_map($this->value(...), $parsed->getTerms());
private function value(mixed $n): string
{
return match (true) {
is_string($n) => $n,
$n instanceof \BackedEnum => (string) $n->value,
default => (string) $n->getValue(),
};
}
If you were on the pre-3.0 defensive is_string($n) ? $n : $n->getValue() form, it will fatal on the new enum cases (BackedEnum has no getValue()). Update it and add a test that asserts the enum path actually runs.
Wrap translation in your own builder. Implement a small class that turns the parsed collections into Eloquent constraints or a Scout/Elasticsearch body. This is your extension point if you later swap the library out.
$query = Invoice::query()
->when($terms, fn ($q) => $q->where(function ($q) use ($terms, $phrases) {
foreach ($terms as $t) { $q->orWhere('reference', 'like', "%{$t}%"); }
foreach ($phrases as $p) { $q->orWhere('notes', 'like', "%{$p}%"); }
}))
->when($hashtags, fn ($q) => $q->whereHas('tags', fn ($t) => $t->whereIn('name', $hashtags)))
->when($mentions, fn ($q) => $q->whereIn('assignee_handle', $mentions));
For Scout/Elasticsearch, use term/match_phrase for phrases and terms filters for hashtags/mentions — do not concatenate parsed output back into a query string.
Memoize per request. Parsing is cheap but not free, and retries/partials can call it repeatedly. A request-scoped array<string, ParsedQuery> keyed by md5($input) inside the singleton is enough. Do not cache the raw ParsedQuery across requests — the node graph now contains enum cases that don't round-trip cleanly through serialize()/json_encode() without extra work.
Keep a no-syntax fallback. If getTerms() and friends are all empty (blank input, or input the parser silently drops), fall back to a plain substring search so the user gets something rather than an empty page.
->getValue(), or stored nodes directly, audit. Comparisons should be $node === SomeEnum::Foo (identity works for enum cases) or against ->value. Serialising a ParsedQuery to JSON now requires casting enum cases to their ->value first — json_encode will otherwise produce {} or throw.parse() in try/catch and return a 422; do not let a stray " 500 the page. Fuzz this with "", "a, ((, #, @, emoji, and 10k-character tokens.# and @ collide with legitimate input. Searching for C#, an email address, or a Slack handle produces surprising tokens. If your domain allows those, sanitize or require whitespace-delimited tags before parsing.AND/OR/NOT uppercase). Document this for users; consider uppercasing operators client-side.+/-/OR handling varies between ports and versions — confirm against the source rather than trusting the README example. This is doubly true after a major refactor where the README may be stale.config/query-parser.php.3.0.0, not ^3.0), run your test suite on 8.1+ and 8.2+, and keep it behind your own SearchQueryParser interface so a fork or replacement (custom tokenizer, spatie/laravel-searchable) is a one-file change.Node subclasses rather than patching QueryParser. With enum cases in the tree, keep your visitor tolerant of both enum and scalar values until you have confirmed the shape across all node types.How can I help you explore Laravel packages today?