mnugter/jsonapi-rql-finder-bundle
Symfony/PHP bundle that integrates an RQL finder into JSON:API resources, letting clients filter, sort, and paginate with expressive query expressions. It bridges RQL parsing and Doctrine queries so endpoints return consistent, spec-compliant responses with less custom code.
This package is an adapter, not a standalone API: it plugs RQL filter syntax (eq, and, or, in, like, …) into the finder/query layer of paknahad/jsonapi-bundle. You need that bundle installed and at least one JSON:API resource (model/endpoint) working before this adds value.
Install alongside the base bundle, on PHP 8
composer require paknahad/jsonapi-bundle mnugter/jsonapi-rql-finder-bundle:^3.0
3.0.0 is the PHP 8 release. The release note is a single line — "Support for PHP 8" — with no changelog and no stated breaking changes, but a major-version bump for what looks like a platform requirement almost certainly means PHP 7.x is dropped from the composer.json php constraint. Check that constraint before you plan the upgrade, and if you're still on PHP 7.4 stay on the pre-3.0 line (pin it exactly). For PHP 8 projects, ^3.0 is the right constraint; for anything you intend to keep, pin to the exact tag and diff the src/ between your old and new version yourself — the release notes won't tell you what moved.
Wire it in. The package is shipped as a bundle, so in a Symfony host app you register it in config/bundles.php and import any config file the README documents. If you're coming from Laravel (as most readers will), there is no ServiceProvider here — you either run the underlying paknahad/jsonapi-bundle if it is Laravel-native, or lift the RQL parser/operator classes out of src/ and bind them in a provider. Do that evaluation before writing feature code; the parser is the reusable part, the container wiring is not.
Where to look first — in this order:
filter query-param shape),src/ for the finder class and the operator map (this is what decides which RQL functions exist and how they translate to query builder calls),paknahad/jsonapi-bundle's finder/query contract, since that is the seam this package hooks into.First use case. Take an existing endpoint and replace hand-rolled query params:
GET /api/articles?filter=eq(status,published)
GET /api/articles?filter=and(eq(status,published),gt(views,100))
Confirm the generated SQL through the Symfony profiler (or Laravel's query log) before trusting it. If those two work, or, in, and like will too — they exercise the same code path.
Use filter as one opaque string, not a bag of params. The whole point of the package is that ?status=published&views_gt=100 becomes ?filter=and(eq(status,published),gt(views,100)). Build it client-side with http_build_query(['filter' => $rql]) so parentheses, commas, and * are encoded; never hand-concatenate URLs.
Compose, don't override, the JSON:API params. Keep RQL in filter and leave sort, page[number], page[size], include, and fields[...] to the base bundle. Do not try to smuggle sort(...) or limit(...) through filter unless the README explicitly documents that — you'll end up with two competing pagination mechanisms and mismatched meta.total counts.
Allowlist what can be filtered. The finder will happily map RQL field names onto columns. Add a per-resource map (config or a small resolver class) of exposed fields and permitted operators, so eq(password_hash,...) or like(api_token,*a*) is rejected with a 400 rather than executed. This is the single most important integration step and the one most likely to be missing from a small package's defaults.
Keep the controller thin. The resource/controller should receive the document request, hand the filter to the finder, and serialize the result. If you find yourself parsing the RQL string in a controller, you're duplicating what the bundle already does and you'll drift from its grammar.
Front-end contract: build a tiny RQL serializer. Filter chips in a UI map cleanly to an AST ({and: [{eq: ['status','published']}, {gt: ['views',100]}]}) that serializes to RQL. Centralizing this avoids every screen inventing its own string format and makes the query string cacheable/normalizable (sorted operands = stable ETag/cache key).
Custom operators as the extension point. Where you need domain semantics (eq(tag,php) on a pivot table, full-text, isnull), register a custom operator/scope rather than post-processing results in PHP — post-processing breaks pagination counts. On 3.0.0 you can use PHP 8 syntax (constructor promotion, union types, named arguments) in your own operator classes, but check the base classes' signatures — if the package's own interfaces are still untyped for BC with 7.x-era code, your overriding method must match the parent's laxer signature or PHP throws a fatal.
Test at the boundary. Feature tests that assert on the emitted query bindings for a handful of representative filters (and, nested or, in, a quoted string containing a comma) catch more regressions than unit-testing the parser internals. Add one negative test for a disallowed field and one for malformed RQL.
Map parse failures to JSON:API errors. A malformed filter should produce a 400 with an errors[] object, not a 500. Wrap the finder call and translate the parser exception.
src/ end to end before you build on it — the source is the documentation.composer update to wave through.^8.0, it may never have been run on 8.2/8.3, where dynamic properties, ${var} interpolation in strings, and implicit nullable parameters all emit deprecations. Suppress nothing until you've read the deprecation log once; a 2022-era codebase commonly trips these in query-builder glue code.config/bundles.php, and HttpFoundation-oriented services. Budget time for either adapting the parser into a service provider or accepting a second framework in your stack. Don't assume composer require + auto-discovery just works.filter[field]=value (array), which arrives as an array, not a string — the parser will choke. Normalize filter to a string at the edge. Watch for , and ) being truncated by proxies or unencoded clients, and for * in like() being eaten by shell/curl tests.eq(name,' OR 1=1 --) and check the query log. If any operator concatenates raw input, wrap or replace it.gt(created_at,2022-01-01)), booleans, and nulls are the usual failure points, and MySQL/Postgres disagree on casting. Use isnull/explicit equality forms where the grammar supports them, and test both engines if you deploy both.whereHas/join, and joins will change your total count query — verify counts on filtered, relation-filtered sets.and(or(and(...))) strings and very long in() lists can recurse or blow up SQL. Cap filter length and nesting depth before parsing.paknahad bundle matters. If filters appear to be ignored, dump the container and confirm your service is the one actually injected.filter string, the parsed AST, and the resulting SQL bindings at debug level. Three lines of logging will save hours against a package with no community answers.How can I help you explore Laravel packages today?