asseco-voice/laravel-json-query-builder
Build complex Eloquent queries from JSON with asseco-voice/laravel-json-query-builder. Turn structured JSON payloads into safe, readable filters and sorts, making API-driven search and dynamic filtering simple in Laravel.
The package turns a JSON payload (typically a request body or query string deserialized to an array) into an Eloquent query — filters, sorts, eager loads, field selection, and pagination. Start with the README on GitHub since the API surface is small and the docs are the primary source; there is no generated API reference.
Install
composer require asseco-voice/laravel-json-query-builder
last release: 2024-03-04 — pin it ("asseco-voice/laravel-json-query-builder": "^1.0" or whatever the exact tag is) so an unexpected composer update on a 24-star package doesn’t silently change behavior.
Where to look first
vendor/asseco-voice/laravel-json-query-builder/README.md — the canonical usage example.vendor/asseco-voice/laravel-json-query-builder/src/ — usually a small set of classes: a builder/facade entrypoint, a filter handler, and operator definitions. Read these before writing production code; at this size you can audit the whole package in ~15 minutes. Do that — see Gotchas.config/ in the vendor package if a config file is shipped — copy it with php artisan vendor:publish --tag=... (check the README for the exact tag, or php artisan vendor:publish and pick the vendor package).First use case — a filtered list endpoint
In a controller, build the query from the request body instead of hand-rolling when() chains:
use AssecoVoice\JsonQueryBuilder\Facades\JsonQueryBuilder; // adjust to actual entrypoint
public function index(Request $request)
{
return UserResource::collection(
JsonQueryBuilder::for(User::class)
->fromJson($request->json()->all())
->paginate()
);
}
The exact entrypoint (facade, static make, trait, or a builder class) is worth confirming in the README — the rest of this assessment assumes the common shape: for(model), fromJson(array), then a terminal (get, paginate, first).
A minimal JSON body looks like:
{
"filter": { "status": "active", "created_at": { "gte": "2024-01-01" } },
"sort": ["-created_at"],
"include": ["profile", "roles"],
"fields": { "users": ["id", "name", "email"] }
}
That body alone replaces a lot of boilerplate and is the reason to consider the package in the first place.
Controller as a thin pass-through. Keep the JSON contract in one place; don’t sprinkle filter logic across controllers. The package’s value is that the shape of ?filter[...] / body JSON is the API contract — document it, then let every list endpoint honor the same contract.
Whitelist before you build. Always gate the allowed columns, operators, and relations:
JsonQueryBuilder::for(Invoice::class)
->allowedFilters(['status', 'customer_id', 'created_at'])
->allowedSorts(['created_at', 'total'])
->allowedIncludes(['customer', 'lines'])
->fromJson($request->json()->all())
->paginate(50);
If the package doesn’t expose allowed* methods, wrap it in your own service that strips unknown keys before calling fromJson (see Gotchas — this is the single most important pattern).
Reusable query service. For anything beyond a trivial endpoint, wrap the builder:
final class InvoiceQuery
{
public function build(Request $request): LengthAwarePaginator
{
return JsonQueryBuilder::for(Invoice::class)
->allowedFilters(Invoice::FILTERS)
->allowedSorts(Invoice::SORTS)
->allowedIncludes(Invoice::INCLUDES)
->fromJson($request->json()->all())
->paginate(min((int) $request->input('per_page', 25), 100));
}
}
This gives you one place to change defaults (page size, max includes) and one place to test.
Compose with policies, not after them. fromJson can request relations and columns you did not intend to expose. Authorize at the model/relation level inside the query service (or filter the JSON payload through a policy) so a client cannot eager-load customer.billingCards just by asking for it.
Pair with API Resources. The builder produces models; keep transformation in JsonResource classes so the JSON query contract stays decoupled from the JSON response contract. This also makes it easy to swap the query builder later if needed.
Nested / relationship filters. For filters on related models, express them in the JSON and let the builder join or eager-load; verify the README covers relation filters and confirm the generated SQL (see Gotchas). If relation filtering isn’t supported, do the base filter with the package and chain manual whereHas on the resulting builder — the builder returns a standard Builder, so everything remains composable.
Testing. Feature-test the HTTP endpoints with a matrix of JSON bodies (one test per operator, one for unknown column, one for unauthenticated include). Because the input is user-controlled JSON, this is the layer where regressions hurt most.
User-supplied JSON is an attack surface. A package that maps arbitrary keys to columns and relations will happily orderBy('password'), eager-load huge relations, or filter on hidden columns unless you restrict it. Treat the JSON as untrusted: validate keys against server-side allowlists, and never pass $request->all() straight in without filtering. If the package lacks allowed* guards, add them yourself — this is non-negotiable in production.
Low maintenance signal. 24 stars, last release March 2024. Audit src/, then pin the version and plan for a fork if the maintainer stops responding. The classes are small, so forking is a realistic fallback; wrapping it in your own service (above) makes that switch cheap.
Debug by dumping SQL, not by reading the JSON. When a filter behaves unexpectedly, call ->toSql() and ->getBindings() (or ->dd()) on the underlying builder before executing. Most surprises are a key the builder silently ignored, an operator it mapped differently than you expected (e.g. gte vs >=), or a join you didn’t intend.
Silent no-ops. Builders like this often ignore unknown keys rather than throwing. That is friendly in dev and dangerous in prod — a typo in a client’s JSON looks like “no filter applied” rather than an error. Add a strict mode in your wrapper: assert every top-level key (filter, sort, include, fields) and every filter column is in your allowlist, and return a 422 otherwise.
Type and date coercion. JSON gives you strings; the DB wants typed bindings. Dates and booleans are the usual suspects — check whether the package casts them or whether you need to normalize before fromJson. For in/between operators, confirm the payload shape (["a","b"] vs { "min": ..., "max": ... }) in the README, because guessing wrong fails quietly.
Pagination caps. If paginate() reads page size from the payload or query string, cap it in your wrapper (min($per_page, 100)). Otherwise a single client request can pull the whole table and blow up memory / response size.
Eager-load depth. include with nested paths (customer.company.address) can generate deep joins or huge result sets. Limit depth and the set of allowed includes in your allowlist; consider ->withCount-style replacements where the client only needs “existence”, not the full relation.
Config quirks. If the package ships a config file (operator aliases, default page size, date format), publish it and commit it to your repo so behavior is reproducible across environments. Don’t rely on vendor defaults — an upgrade can change them.
Extension points. Before forking, check src/ for interfaces for custom operators or filter handlers. Adding a domain-specific operator (e.g. near, within) through the package’s extension hook is far cheaper than maintaining a fork. If no hook exists, a decorator service around fromJson is usually sufficient.
Log the raw JSON on 4xx/5xx. Because the input drives the SQL, an error report containing only “500” is unactionable. Log the JSON payload (with allowlisted keys) alongside the failing query so you can reproduce.
How can I help you explore Laravel packages today?