Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Query Builder Parser Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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%"]

Implementation Patterns

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.

Gotchas and Tips

  • Unknown fields are silently dropped, not rejected. A field outside the allow-list simply doesn't produce a predicate — the UI shows "filtered" and the user gets unfiltered data. If a filter is user-visible, validate fields yourself and return a 422 on misspellings.
  • No depth or node-count cap ships with the package. A crafted 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.
  • Dates became Carbon values in 1.5. Date-ish strings are run through Carbon for SQL comparison; malformed input throws. Validate date format on the way in and catch parse exceptions around parse(), returning 422 — the raw exception is not a user-facing message.
  • Empty 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.
  • Version-pin and check the changelog for operator bugs. 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.
  • Empty or absent rules must short-circuit. 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.
  • Nothing here knows about model scoping. Soft-deleted rows, tenant isolation, and global scopes only apply if you started from Model::query(). Never let a parsed builder reach a table directly if the table has tenancy or deletion semantics.
  • MongoDB is a separate integration. The $regex path requires jenssegers/mongodb, which has been superseded by mongodb/laravel-mongodb. Treat it as its own evaluation, not a free switch.
  • Debug with 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".
  • Bus factor is real. 160 stars, zero dependents, single maintainer, and it sits directly in front of your database. Keep the allow-list and rule guard in your code (not in a fork), pin the version, and have a one-afternoon contingency to vendor the class if releases lapse. Wrapping it behind your own FilterService interface means a fork or replacement is a swap, not a refactor.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings