Query Builder Filter
QueryBuilderFilter lets the user compose the conditions themselves: a flat list of rules, each naming a column, an operator, and a value. Reach for it when you cannot predict what a reader will want to ask of the table — a support view, a report, an audit log. When the shape of the question is known, a SelectFilter or a FormFilter is the better answer.
A minimal working example
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Users\Tables;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\Filters\Constraints\TextConstraint;
use PandaPanel\Tables\Filters\QueryBuilderFilter;
use PandaPanel\Tables\TableSchema;
final class UsersTable
{
public static function configure(TableSchema $table): TableSchema
{
return $table
->columns([
TextColumn::make('name'),
TextColumn::make('email'),
])
->filters([
QueryBuilderFilter::make('conditions')
->label('Advanced')
->constraints([
TextConstraint::make('name'),
TextConstraint::make('email'),
]),
]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
The table now offers an "Advanced" filter where the user adds rules such as Name contains ada. Rules are ANDed and applied inside their own group, so they narrow whatever the search and the other filters already left.
How a rule is checked
Every part of a submitted rule is looked up against a declaration before it reaches the builder. Nothing is concatenated from the request: the column string comes from the Constraint object and the comparison from a closed enum.
| Part of the rule | Checked against | Failure |
|---|---|---|
column | QueryBuilderFilter::constraint($name) | rule dropped |
operator | ConstraintOperator::tryFrom(), then Constraint::supports() | rule dropped |
value | Constraint::accepts($operator, $value) | rule dropped |
A rule failing any of those is dropped, not repaired — a query the user did not describe is worse than no rule. When every rule is dropped, sanitize() returns null, the filter applies nothing, and it reports as inactive, so the frontend never shows a chip for a condition the query ignored.
QueryBuilderFilter
use PandaPanel\Tables\Enums\FilterType;
use PandaPanel\Tables\Filters\Constraints\Constraint;
use PandaPanel\Tables\Filters\QueryBuilderFilter;
QueryBuilderFilter::make(string $name): static
QueryBuilderFilter::constraints(array $constraints): self // array<array-key, Constraint>
QueryBuilderFilter::maxRules(int $max): self // default 10, floored at 1
QueryBuilderFilter::constraint(string $name): ?Constraint
QueryBuilderFilter::sanitize(mixed $value): ?array
QueryBuilderFilter::type(): FilterType // FilterType::QueryBuilder2
3
4
5
6
7
8
9
10
use PandaPanel\Tables\Filters\Constraints\BooleanConstraint;
use PandaPanel\Tables\Filters\Constraints\DateConstraint;
use PandaPanel\Tables\Filters\Constraints\NumberConstraint;
use PandaPanel\Tables\Filters\Constraints\TextConstraint;
use PandaPanel\Tables\Filters\QueryBuilderFilter;
QueryBuilderFilter::make('conditions')
->label('Advanced')
->maxRules(5)
->constraints([
TextConstraint::make('name'),
TextConstraint::make('email'),
NumberConstraint::make('login_count')->label('Sign-ins'),
DateConstraint::make('created_at')->label('Registered'),
BooleanConstraint::make('is_admin')->label('Administrator'),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
maxRules() bounds one filter so it cannot become an unbounded pile of conditions. Rules past the limit are discarded while parsing; the frontend also stops offering "add condition" once the list reaches it.
Everything on the base Filter is inherited and works here:
use Illuminate\Database\Eloquent\Builder;
QueryBuilderFilter::make('conditions')
->label('Advanced')
->constraints([TextConstraint::make('name')])
->default([['column' => 'name', 'operator' => 'is_filled']])
->modifyBaseQueryUsing(static fn (Builder $query) => $query->withoutGlobalScope('published'));2
3
4
5
6
7
default() takes the same array shape a request would send and is validated the same way. query() replaces the whole constraint — it receives the sanitized rules, which are ['constraint' => Constraint, 'operator' => ConstraintOperator, 'value' => mixed] triples rather than raw request arrays, so a custom query() here is rarely what you want.
Constraints
A Constraint is one column the user is allowed to talk about. Four are shipped.
| Class | inputType() | For |
|---|---|---|
PandaPanel\Tables\Filters\Constraints\TextConstraint | text | strings |
PandaPanel\Tables\Filters\Constraints\NumberConstraint | number | numeric columns |
PandaPanel\Tables\Filters\Constraints\DateConstraint | date | dates and datetimes |
PandaPanel\Tables\Filters\Constraints\BooleanConstraint | none | flags |
inputType() is the type attribute of the value input the frontend renders. none means the operators carry their own answer and no value box is drawn.
Every constraint shares this API:
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Enums\ConstraintOperator;
use PandaPanel\Tables\Filters\Constraints\Constraint;
Constraint::make(string $name): static
Constraint::label(string $label): static
Constraint::column(string $column): static // when the database column differs from the name
Constraint::getName(): string
Constraint::getLabel(): string // Str::headline($name) unless labelled
Constraint::getColumn(): string // $column ?? $name
Constraint::operators(): array // list<ConstraintOperator>
Constraint::inputType(): string
Constraint::supports(ConstraintOperator $operator): bool
Constraint::accepts(ConstraintOperator $operator, mixed $value): bool
Constraint::apply(Builder $query, ConstraintOperator $operator, mixed $value): void
Constraint::toArray(): array2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use PandaPanel\Tables\Filters\Constraints\TextConstraint;
// The request says "reference"; the query touches `orders.slug`.
TextConstraint::make('reference')->label('Order reference')->column('slug');2
3
4
Which operators each constraint offers
| Operator | Reads as | TextConstraint | NumberConstraint | DateConstraint | BooleanConstraint |
|---|---|---|---|---|---|
Contains | contains | ✓ | |||
DoesNotContain | does not contain | ✓ | |||
StartsWith | starts with | ✓ | |||
EndsWith | ends with | ✓ | |||
EqualTo | is | ✓ | ✓ | ✓ | |
NotEqualTo | is not | ✓ | ✓ | ||
GreaterThan | is after | ✓ | ✓ | ||
GreaterThanOrEqual | is at least | ✓ | ✓ | ||
LessThan | is before | ✓ | ✓ | ||
LessThanOrEqual | is at most | ✓ | ✓ | ||
IsFilled | is filled | ✓ | ✓ | ✓ | ✓ |
IsBlank | is blank | ✓ | ✓ | ✓ | ✓ |
IsTrue | is true | ✓ | |||
IsFalse | is false | ✓ |
What each operator becomes
PandaPanel\Tables\Enums\ConstraintOperator is the only place an operator ever comes from, and each case maps to one builder call.
| Case | Value | Builder call | needsValue() |
|---|---|---|---|
Contains | contains | where($c, 'like', '%value%') | true |
DoesNotContain | does_not_contain | whereNot($c, 'like', '%value%') | true |
StartsWith | starts_with | where($c, 'like', 'value%') | true |
EndsWith | ends_with | where($c, 'like', '%value') | true |
EqualTo | equal_to | where($c, '=', $value) | true |
NotEqualTo | not_equal_to | where($c, '!=', $value) | true |
GreaterThan | greater_than | where($c, '>', $value) | true |
GreaterThanOrEqual | greater_than_or_equal | where($c, '>=', $value) | true |
LessThan | less_than | where($c, '<', $value) | true |
LessThanOrEqual | less_than_or_equal | where($c, '<=', $value) | true |
IsFilled | is_filled | whereNotNull($c) | false |
IsBlank | is_blank | whereNull($c) | false |
IsTrue | is_true | where($c, '=', true) | false |
IsFalse | is_false | where($c, '=', false) | false |
The three LIKE operators escape \, %, and _ in the value, so a term containing a wildcard matches literally instead of scanning the table.
ConstraintOperator::label() returns the human reading in the second column of the first table; needsValue() decides whether a value input is drawn and whether accepts() demands one.
Which values a constraint accepts
// Constraint (base): a value is required unless the operator carries its own answer.
public function accepts(ConstraintOperator $operator, mixed $value): bool
{
return ! $operator->needsValue() || (is_scalar($value) && $value !== '');
}2
3
4
5
NumberConstraint narrows that to is_numeric($value) — a comparison against a non-number is not a narrower query, it is a meaningless one, so it is refused rather than coerced to zero. DateConstraint narrows it to a string strtotime() can read, because anything else would be compared as a string, which sorts nothing like a date.
Writing your own constraint
Two abstract methods, and only those two are required:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Tables\Constraints;
use PandaPanel\Tables\Enums\ConstraintOperator;
use PandaPanel\Tables\Filters\Constraints\Constraint;
final class StatusConstraint extends Constraint
{
public function inputType(): string
{
return 'text';
}
/**
* @return list<ConstraintOperator>
*/
public function operators(): array
{
return [
ConstraintOperator::EqualTo,
ConstraintOperator::NotEqualTo,
ConstraintOperator::IsBlank,
];
}
public function accepts(ConstraintOperator $operator, mixed $value): bool
{
return ! $operator->needsValue()
|| in_array($value, ['open', 'closed', 'archived'], true);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Override apply() only when a rule needs a comparison the enum cannot express; the operator has already been checked against operators() before it reaches the method, so the match there is total.
What travels in the URL
The rules live in the table's filter map, so they survive back, forward, refresh, and bookmark like every other piece of table state:
?filters[conditions][0][column]=name
&filters[conditions][0][operator]=contains
&filters[conditions][0][value]=ada
&filters[conditions][1][column]=created_at
&filters[conditions][1][operator]=greater_than
&filters[conditions][1][value]=2026-01-012
3
4
5
6
Turning persistFiltersInSession() on remembers that map with the rest of the filters. See Persisted state.
The indicator
Filter::indicator() builds the chip text on the server, because only the filter knows what its value means. For a query builder it is the label, a colon, and each surviving rule joined with and:
Advanced: Name contains ada and Registered is after 2026-01-01Rules that were dropped are absent from it, which is the visible signal that one was not applied.
Serialized definition
toArray() adds two keys to the base filter definition:
[
'name' => 'conditions',
'label' => 'Advanced',
'type' => 'query_builder',
'default' => null,
'constraints' => [
[
'name' => 'name',
'label' => 'Name',
'input' => 'text',
'operators' => [
['value' => 'contains', 'label' => 'contains', 'needsValue' => true],
// ...
],
],
],
'maxRules' => 10,
]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
No closure, no query, no model class — the frontend renders the columns and comparisons it was given and can invent neither.
Notes
- Nested and/or groups are deliberately absent. They need a recursive schema on both sides and a UI to match, and a flat list of ANDed conditions answers the question most tables are actually asked. Reach for
FormFilterwhen the shape is known and a customquery()when it is not. - A constraint names a column of the table being queried. There is no relation traversal:
TextConstraint::make('author.name')would reach the builder as a table-qualified column, not as awhereHas. Search a relation with a dotted searchable column or narrow it with aFormFilter. DateConstraintaccepts anythingstrtotime()parses, which includes relative strings such asyesterday. That is intentional — the value still goes to the builder as a bound parameter — but a constraint that must only take calendar dates should overrideaccepts().IsTrueandIsFalsecompare againsttrueandfalse. ANULLflag matches neither; ask for it withIsBlank.- A dropped rule is silent. The user sees the condition disappear from the indicator rather than an error. That is the deliberate trade: the alternative is repairing a rule into a query nobody described.
maxRules()truncates. Rules beyond the limit are discarded while parsing rather than making the whole filter fail.