timgws/query-builder-parser
QueryBuilderParser is a Laravel/PHP package that converts jQuery QueryBuilder rules into safe SQL or MongoDB queries using a whitelist of allowed fields. It integrates with Laravel's query builder and jenssegers/mongodb for filtering and search.
Install it like any Illuminate-only package — no service provider, no config publish, no facade:
composer require timgws/query-builder-parser
The entire public contract is one class and one method: construct with an allow-list of filterable columns, then call parse($rules, $builder) where $builder is an Illuminate\Database\Query\Builder. You get the same builder back, mutated with WHERE clauses, ready to chain.
A minimal, working endpoint — the first use case you should build is an admin list/filter screen (users, orders, audit log) where the frontend already runs jQuery QueryBuilder:
use timgws\QueryBuilderParser;
class UserSearchController
{
public function __invoke(Request $request)
{
$allowed = ['id', 'name', 'email', 'created_at', 'active'];
$qbp = new QueryBuilderParser($allowed);
$query = $qbp->parse(
$request->input('rules'), // JSON string from queryBuilder('getRules')
DB::table('users')
);
return response()->json(
$query->orderByDesc('id')->paginate(25)
);
}
}
Frontend half (the part people forget to write):
const rules = $('#querybuilder').queryBuilder('getRules'); // {condition:'AND', rules:[...]}
fetch('/admin/users/search', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-TOKEN': token},
body: JSON.stringify({ rules: JSON.stringify(rules) })
});
Where to look first. All the behaviour lives in src/QueryBuilderParser.php. Read it top-down through parse() → buildQuery() → buildGroups() → buildRule(). The operator→SQL mapping table and the defaultCondition property are the two things you will actually end up customising. JoinSupportingQueryBuilderParser in the same namespace is a subclass worth skimming even if you don't use it. Ignore the DataTables snippet in the README's prose — it shows a stale single-argument parse(DB::table('users')) call and an old Input::get style; the real signature is parse($rules, $builder).
Smoke test before you wire the controller. Assert SQL without touching the database — this is the fastest way to learn the package's semantics:
$b = DB::table('users');
(new QueryBuilderParser(['name', 'email']))->parse(
'{"condition":"AND","rules":[{"field":"name","operator":"contains","value":"tim"}]}',
$b
);
dump($b->toSql(), $b->getBindings());
// select * from `users` where `name` LIKE ?
// ["%tim%"]
1. Make the allow-list a first-class, tested artifact. It is the package's only security boundary. Put it in a dedicated class or method, not inline in the controller, and drive it from the same source of truth as your policies where possible:
final class FilterableColumns
{
public const USERS = ['id', 'name', 'email', 'created_at', 'active'];
}
Add a test that asserts the list explicitly. When someone adds a ssn column, you want a diff on that constant, not a silent new attack surface.
2. Validate and normalise the rule tree before handing it to the parser. Treat the parser as a compiler with no input validation of its own. A thin wrapper gives you depth caps, node caps, per-field operator allow-listing, in-list length limits, and type coercion:
final class RuleGuard
{
private const MAX_DEPTH = 5;
private const MAX_RULES = 40;
public function __construct(private array $fields, private array $operatorsByField = []) {}
public function check(array $node, int $depth = 0): void
{
abort_if($depth > self::MAX_DEPTH, 422, 'Filter too deep');
abort_if(count($node['rules'] ?? []) > self::MAX_RULES, 422, 'Too many filters');
// recurse into $node['rules'], validate field ∈ allow-list,
// operator ∈ permitted set, value type, in-list cardinality...
}
}
This is where all your production hardening lives, and it should be estimated as real work rather than plumbing.
3. Integrate with Eloquent without losing scopes and soft deletes. parse() operates on the query builder, not the Eloquent builder — but you can pass the Eloquent builder's underlying query and keep the wrapper:
$eloquent = User::query()->where('tenant_id', $tenant->id); // scopes still apply
$qbp->parse($rules, $eloquent->getQuery()); // wheres get added
$users = $eloquent->paginate(25); // hydrate normally
Starting from Model::query() means global scopes, SoftDeletes, and your tenant predicate survive. Starting from DB::table() bypasses all of them and returns stdClass rows — fine for read-only admin grids, dangerous for anything multi-tenant.
4. Paginate and count off the same builder. Build once, then derive both:
$base = DB::table('orders');
(new QueryBuilderParser(FilterableColumns::ORDERS))->parse($request->input('rules'), $base);
$total = (clone $base)->count();
$rows = $base->orderByDesc('id')->forPage($page, $per)->get();
The parser does not emit ORDER BY. Always add a deterministic tiebreaker (id) or pagination will shuffle rows between pages — a classic and confusing bug report.
5. DataTables / server-side grids. The rules blob is JSON-encoded once client-side and passed as a normal request parameter (fnServerParams, or the modern ajax.data callback), then decoded server-side. Keep showColumns/orderColumns/searchColumns aligned with the same allow-list constant — three lists drifting apart is the usual failure mode.
6. Reach for JoinSupportingQueryBuilderParser only when you need it. It emits correlated EXISTS / NOT EXISTS subqueries from a declarative mapping:
$joins = [
'last_login' => [
'from_table' => 'users', 'from_col' => 'id',
'to_table' => 'logins', 'to_col' => 'user_id',
'to_value_column' => 'created_at',
'not_exists' => false,
],
];
$qbp = new JoinSupportingQueryBuilderParser($fields, $joins);
$qbp->parse($rules, DB::table('users'));
// where exists (select 1 from logins where logins.user_id = users.id and created_at < ?)
This covers a lot of multi-table filtering, but it is a hand-maintained mapping, not relationship inference. If your filter needs whereHas semantics across a graph, this package is the wrong seam.
7. Subclass for domain-specific operators. The operator table and default condition are overridable; subclass and extend rather than editing vendor code when you need, say, is_after mapped to >, or a custom operator that normalises a value into a range. Keep the subclass small and unit-tested with toSql()/getBindings() snapshots.
8. Bind it, don't new it everywhere. There's no service provider, so register your own singleton or factory with the allow-list injected from a config/permission source. That gives you one place to change the contract and one place to mock in tests.
rules payload can generate a deeply nested or hugely wide WHERE. Cap depth/nodes in a wrapper and set a statement timeout on the connection (max_execution_time, or statement_timeout on Postgres) before exposing this to any untrusted caller.LIKE wildcards are not escaped. contains wraps your value in %…%; a user typing % or _ becomes a wildcard. Escape them (addcslashes($v, '%_\\')) if literal matching matters. Also note LIKE case-sensitivity and collation differ between MySQL and Postgres — your tests should pin the engine you deploy on.parse(), returning 422 — the raw exception is not a user-facing message.in lists are a landmine. The in/not_in handling has been patched more than once. Strip empty-array rules before parsing; an empty IN () is invalid SQL on most engines.is_null / is_not_null take no value. Don't assume the UI omitted value by mistake — these operators intentionally consume none. If you write a custom operator, mirror the internal "operator takes no value" handling rather than special-casing null.begins_with/ends_with were inverted before 1.1.2, and in-clause conditions were broken before 1.1.0. Require ^2.2 (Laravel 12/13 support) and read the release notes when bumping.parse() mutates and returns the builder. Don't stash it on a singleton or reuse it across requests — build a fresh builder per request, or you'll accumulate WHEREs and see impossible queries.queryBuilder('getRules') returns null/{rules:[]} when nothing is set. Guard with empty($rules['rules']) and skip parse() entirely; passing an empty tree through the compiler is a source of subtle no-WHERE behaviour.Model::query(). Never let a parsed builder reach a table directly if the table has tenancy or deletion semantics.$regex path requires jenssegers/mongodb, which has been superseded by mongodb/laravel-mongodb. Treat it as its own evaluation, not a free switch.toSql() + getBindings(), not by eyeballing results. Log both immediately before execution in staging; a wrong-by-one predicate is invisible in a paginated grid. DB::listen() is a reasonable belt-and-braces addition for audit trails of "who filtered on what".FilterService interface means a fork or replacement is a swap, not a refactor.How can I help you explore Laravel packages today?