graviton/rql-parser
A Laravel/PHP parser for Resource Query Language (RQL), turning filter, sort, and pagination expressions into structured query objects you can apply to Eloquent or other data sources. Clean syntax, extensible operators, and framework-friendly integration for flexible APIs.
The package is no longer dormant: v3.0.1 shipped as a bugfix on top of the v3 line, so the "archived, last release 2019" assumption in the previous assessment is outdated. It remains a standalone RQL → AST parser with no Laravel service provider — you still wire it yourself — but you can now target the current major instead of pinning ^1.0 and planning a fork.
composer require graviton/rql-parser:^3.0. Check the changelog for v2/v3 before committing — the major bumps that happened between the ^1.x era and today are where any node-class or getter renames will be documented. Lock a minor (^3.0) rather than a floating constraint.dd() a parse result. The node classes (AndNode, OrNode, Criterion, Sort, Limit, Select) are the API you will actually program against, and their getters are what matters day to day. Verify getter names against your installed version — that is the one place a v1 snippet will bite you.eq, ne, lt, le, gt, ge, like, in, out), boolean composition (and, or, not), plus sort, limit, and select. That is exactly the shape of GET /api/users?q=eq(status,active)&sort(-created_at)&limit(0,25).Minimal wiring (class names: verify against your pinned version — the parse result is a small root object wrapping the filter tree plus the sort/limit/select nodes):
use Graviton\RqlParser\Parser;
$parser = Parser::createDefault(); // custom criteria get registered here
$root = $parser->parse($request->query('q', ''));
// dd($root); // <- do this before writing any compiler
Return 400 on parser exceptions (SyntaxError-style) and never let a malformed q reach your query builder.
1. Controller → filter service → compiler. Keep the parser at the edge, never in models. The controller hands the raw string to a service that returns a configured Eloquent builder.
final class RqlFilter
{
public function __construct(private array $config) {}
public function apply(Builder $query, string $rql): Builder
{
try {
$root = Parser::createDefault()->parse($rql);
} catch (\Throwable $e) {
throw new InvalidArgumentException('Invalid RQL', 0, $e);
}
$compiler = new RqlToEloquent($this->config['fields'], $this->config['relations']);
return $compiler->compile($query, $root);
}
}
2. Recursive visitor compiler. Walk the tree once, translating each node. Wrap or() in a closure every time or Eloquent's AND/OR precedence will silently flatten your logic.
final class RqlToEloquent
{
public function __construct(private array $fields, private array $relations) {}
public function compile(Builder $query, $root): Builder
{
foreach ($root->queries() as $filter) { // adapt: getQueries()/getFilters()
$this->node($query, $filter, 'and');
}
foreach ($root->sort() as $sort) { // adapt: getSort()
$column = $this->column($sort->getPath());
$query->orderBy($column, $sort->getDirection() === '+' ? 'asc' : 'desc');
}
if ($limit = $root->limit()) { // RQL is limit(offset, count)
$query->offset(min($limit->getOffset(), 10_000)) // int as of v3.0.1
->limit(min($limit->getLimit(), 100));
}
if ($select = $root->select()) {
$query->select($this->columns($select->getFields())); // whitelist!
}
return $query;
}
private function node(Builder $qb, $node, string $boolean): void
{
if ($node instanceof AndNode) {
$qb->where(fn ($q) => array_walk($this->children($node), fn ($c) => $this->node($q, $c, 'and')), null, null, $boolean);
return;
}
if ($node instanceof OrNode) {
$qb->where(fn ($q) => array_walk($this->children($node), fn ($c) => $this->node($q, $c, 'or')), null, null, 'or');
return;
}
if ($node instanceof NotNode) {
$qb->whereNot(fn ($q) => $this->node($q, $this->children($node)[0], 'and'));
return;
}
[$column, $operator] = $this->resolve($node->getCriterion());
$value = $this->cast($node->getValue());
match ($operator) {
'eq' => $qb->{$boolean === 'or' ? 'orWhere' : 'where'}($column, '=', $value),
'ne' => $qb->{$boolean === 'or' ? 'orWhere' : 'where'}($column, '!=', $value),
'lt','le','gt','ge' => $qb->{$boolean === 'or' ? 'orWhere' : 'where'}($column, $this->sqlOp($operator), $value),
'in' => $qb->{$boolean === 'or' ? 'orWhereIn' : 'whereIn'}($column, (array) $value),
'out' => $qb->{$boolean === 'or' ? 'orWhereNotIn' : 'whereNotIn'}($column, (array) $value),
'like' => $qb->{$boolean === 'or' ? 'orWhere' : 'where'}($column, 'like', $this->like($value)),
};
}
}
3. Whitelist config, not schema introspection. The parser knows nothing about your database. Maintain an explicit map of RQL fields → columns/relations, and which operators each supports:
// config/rql.php
return [
'fields' => [
'status' => ['column' => 'status', 'ops' => ['eq','ne','in','out']],
'created_at' => ['column' => 'created_at', 'ops' => ['gt','ge','lt','le']],
'name' => ['column' => 'name', 'ops' => ['eq','like']],
],
'relations' => [
'company.name' => 'company', // -> whereHas('company', fn($q) => $q->where('name', ...))
],
];
Dotted paths (eq(company.name,Acme)) must be split; use whereHas for relations, where for columns. Reject anything not in the map.
4. Saved filters. Because RQL is plain text, it is a great format for persisted frontend filters, alert subscriptions, and "recent views." Store the RQL string, re-parse on use, and version it if you change your whitelist.
5. Testing with data providers. RQL is deterministic string-in/AST-out — perfect for table-driven tests. Assert on the generated SQL + bindings ($query->toSql(), $query->getBindings()), not on Eloquent results. Add explicit cases for limit(25) (no offset), limit(0,25), and limit(1000000,1000000) — the offset shape is exactly what v3.0.1 changed.
RqlFilter adapter so a future major, or a swap to another RQL library, doesn't touch controllers.^1.x is a major-version migration. The previous advice to pin ^1.0 and expect deprecation noise on PHP 8.1+ no longer applies to the v3 line, but any v1-era code you already have (getter names, node class names, LimitNode shape) must be diffed against v3 before you assume it compiles.LimitNode offsets are now always integers. v3.0.1's fix means getOffset() no longer returns null/string for a bare limit(25). Practical consequence: min($limit->getOffset(), 10_000) is safe on v3.0.1+, but on anything earlier a null offset could reach offset() and generate broken SQL — if you're pinned below the fix, cast with (int) $limit->getOffset() explicitly. Either way keep the min() clamp on the count.sort(password) or select(id,api_token) is one careless whitelist away from leaking data or crashing on an unknown column. Validate against config/rql.php and reject unknown fields with 422.eq(age,18) gives you "18". Cast to int/float/Carbon in one cast() method keyed by the field's declared type. Dates need a consistent format; normalize to Carbon before binding.eq(name,John%20Doe) and eq(name,John Doe) explicitly; urldecode($raw) at the boundary is usually the safest.like wildcards. RQL uses * (like(name,*acme*)); translate * → % and escape user-supplied _ and % so a search box can't turn into a table scan or pattern injection.not() and NULL. ne(field,x) compiles to !=, which drops NULL rows in SQL. If the semantic you want is "everything except," use whereNot(...)/whereNull handling deliberately.in/out with empty or single-element tuples. in(status,(a,b)) should become a real whereIn; in(status,(a)) and in(status,()) are edge cases — decide whether empty in means "always false" and handle it instead of passing an empty array to Eloquent.limit(offset,count) is not page-based. Apply a server-side max (min($count, 100)) — RQL will happily ask for 10 million rows.like(created_at,*2024*) and gt(name,10) should be rejected by the field's ops list, not silently coerced.dd($root) on day one; then keep a RqlDumper that prints the AST as a nested array, and log $query->toSql() + getBindings() behind a debug flag. When a filter "doesn't work," 90% of the time it's precedence in a nested or() or a missing whitelist entry, not the parser.Parser::createDefault() exists precisely so you can add operators). Register custom node handling in your compiler with a match (true) on class name — that keeps the visitor small as you add between, exists, etc.GET list endpoints and reporting filters.How can I help you explore Laravel packages today?