filament/query-builder
A powerful visual query builder for Filament forms. Compose complex rules with nested groups, AND/OR logic, and dynamic conditions, then reuse queries in your Laravel admin panels. Seamlessly integrates with Filament's form components and Eloquent models.
Prerequisites. A Laravel app on PHP 8.1+ with a working Filament panel (v3.x or v4.x), Livewire, and at least one Eloquent-backed Table. This package is a subtree split of the main Filament monorepo — it is not an independent library. That single fact drives most of its behaviour: its version must match your Filament major, and its API surface follows Filament's release cadence.
Install.
composer require filament/query-builder
Pin it to the same major as filament/filament in composer.json (e.g. Filament 4 → "filament/query-builder": "^4.0"), and let Composer resolve it alongside the panel package rather than installing it into a project that pins an incompatible Filament.
Where to look first.
src/Filters/QueryBuilder.php — the filter component: constraints(), getConstraints(), and the apply()/query-modification path.src/Filters/QueryBuilder/Constraints/Constraint.php — the base class you extend for custom columns.src/Filters/QueryBuilder/Rules/Rule.php — the base class for custom operators.Ignoring the docs and reading those four files is often faster than searching GitHub issues, because the package is small and inheritance-driven.
First use case. Collapse a row of brittle SelectFilter/Filter instances on a large table into one QueryBuilder filter. This is the highest-value, lowest-risk entry point — you get arbitrary AND/OR trees over the columns you expose, with zero schema changes.
use Filament\Tables\Filters\QueryBuilder;
use Filament\Tables\Filters\QueryBuilder\Constraints\BooleanConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\DateConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\NumberConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\SelectConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('reference')->searchable(),
TextColumn::make('customer.name'),
TextColumn::make('status')->badge(),
TextColumn::make('total')->money('gbp'),
TextColumn::make('placed_at')->dateTime(),
])
->filters([
QueryBuilder::make()
->label('Advanced filter')
->constraints([
TextConstraint::make('reference')->label('Reference'),
TextConstraint::make('customer.name')
->label('Customer')
->relationship('customer'),
SelectConstraint::make('status')
->options(OrderStatus::class)
->multiple(),
NumberConstraint::make('total'),
DateConstraint::make('placed_at'),
BooleanConstraint::make('is_priority')->label('Priority'),
])
->deferFilters(false), // see Gotchas — pick one deliberately
]);
}
Verify by filtering for a status you can count, then adding a nested "any/all" group. If the row counts match Order::where(...) in Tinker, you are wired correctly.
Treat the constraint list as a public API for your table, not an auto-generated mirror of the schema. Expose the 6–10 columns users actually reason about. Constraints are cheap to add and expensive to remove once analysts have saved queries against them.
Map each constraint to the exact qualified column, use ->label() for human names, and pass enums or option arrays to SelectConstraint so the UI shows labels while the query keeps raw values.
For related data, prefer ->relationship('customer') on the constraint over hand-written joins in modifyQueryUsing(). The package resolves it to a correlated whereHas subquery, which keeps the main table's row set stable and avoids duplicate rows that a join would introduce when a customer has multiple children.
NumberConstraint::make('customer.lifetime_value')
->label('Customer LTV')
->relationship('customer'),
When a constraint needs logic the built-ins can't express, extend Constraint and implement apply(). This is the package's primary extension point and the place to put tenant scoping, cast handling, or custom index-friendly predicates.
use Filament\Tables\Filters\QueryBuilder\Constraints\Constraint;
use Illuminate\Database\Eloquent\Builder;
class MetaTagConstraint extends Constraint
{
public function apply(Builder $query, string $qualifiedColumn): Builder
{
// Reuse the rule's operator and value, but target a JSON path
// so the query stays indexable via a functional index.
return $query->whereJsonContains('metadata->tags', $this->getValue());
}
public function getDefaultLabel(): ?string
{
return 'Metadata tag';
}
}
Give it a form schema with ->form(fn (Schema $schema) => ...) so users can type a value, and register rules via addRules([...]) if it supports more than one operator.
Operators ("is greater than", "contains", "is empty") are Rule subclasses. Add one when your domain has a predicate that users ask for repeatedly — e.g. "was refunded within 30 days", "is in my team's region". Rules are reusable across every constraint, so this is the highest-leverage extension after constraints.
Extract shared filter sets into a small class or trait so the same constraints appear on an orders table, an export action, and a reporting widget:
final class OrderQueryFilter
{
public static function make(): QueryBuilder
{
return QueryBuilder::make()
->constraints([/* ... */]);
}
}
// In multiple resources
->filters([OrderQueryFilter::make(), ...])
QueryBuilders emit a lot of Livewire round-trips. Pair the filter with deferred filters so users compose a whole tree and apply it once:
->filters([...], layout: FiltersLayout::AboveContent)
->deferFilters()
->persistFiltersInSession()
Persisting in session is what stops the tree from collapsing back to empty when a user navigates to a record and back.
The filter's state is plain array data in the Livewire component, so it round-trips into JSON. Store it against the user and re-hydrate it as the filter's default per-user:
saved_filters table: user_id, filterable (table name), name, payload (JSON), is_shared.tableFilters on the Livewire component).This unlocks "shared views", dashboards, and eventually scheduled reports, all from one stored JSON blob.
The pattern that pays off in day-to-day work: apply the same filter instance to a chart or CSV export so the numbers on the dashboard match the table beneath it.
// Export action
->action(function (array $data, Component $livewire) {
$query = static::getEloquentQuery();
// Apply the table's active filters to the export query.
$query = $livewire->getTable()->getFilteredQuery($query); // verify method name for your Filament major
return Excel::download(new OrdersExport($query), 'orders.xlsx');
})
If that helper differs in your version, replicate it by iterating $livewire->getTableFilters() and calling each filter's apply on your export builder. Always test that an empty filter set returns the unfiltered query.
Query builders are ordinary Eloquent predicates once applied, so test at two levels:
it('filters orders by nested status and total rules', function () {
Order::factory()->create(['status' => 'paid', 'total' => 500]);
Order::factory()->create(['status' => 'paid', 'total' => 10]);
livewire(ListOrders::class)
->filterTable('queryBuilder', [
'rules' => [
['type' => 'status', 'data' => ['operator' => 'is', 'settings' => ['values' => ['paid']]]],
['type' => 'total', 'data' => ['operator' => 'isGreaterThan', 'settings' => ['value' => 100]]],
],
])
->assertCanSeeTableRecords(Order::where('total', 500)->get())
->assertCanNotSeeTableRecords(Order::where('total', 10)->get());
});
The exact filterTable payload shape is determined by your constraint names and the rules' form structures — snapshot one from the browser (Livewire devtools → component effects) rather than guessing. Also add a pure-unit test for each custom constraint: instantiate it, call apply() on a Builder, and assert toSql() plus bindings.
Version coupling is the real risk. Because this is a monorepo subtree split, a Filament major bump can change Constraint, Rule, or the filter's state shape with no separate changelog entry for this package. Pin both packages to the same major, read Filament's upgrade guide before bumping, and keep every custom constraint behind a test that asserts generated SQL — that test is your canary.
Ambiguous column errors. The moment a query touches a joined table, unqualified columns break with Column 'status' in where clause is ambiguous. Pass qualified names (orders.status) or ->relationship(); do not rely on the base table's column name alone, especially in constraints used by both the table and an export that adds joins.
Empty state must be a no-op. Verify explicitly that a QueryBuilder with zero rules applies nothing. A filter that degrades to where 1 = 0 or applies a partial default is a silent data-loss bug in exports — the most common way these show up in production is a CSV that "randomly" has 12 rows.
LIKE '%term%' does not use your index. Text constraints default to substring matching, which is a full scan on large tables. For high-cardinality text columns, either drop the constraint, switch the DB to a trigram/full-text index, or add a custom rule that uses the index-friendly operator. Watch DB::listen() under realistic data volumes before shipping, not on a 200-row dev seed.
Nullable columns need explicit intent. "Is empty" against a NULL column is not the same as against ''. If a column is nullable, expose both and let the built-in empty/not-empty rules do the work; otherwise users will report "0 results" that are actually NULL mismatches. Same trap with JSON columns and Eloquent casts — the filter queries the database, not your accessor.
Timezone drift on date constraints. Date rules compare against the DB's stored values, which are usually UTC while users think in local time. Set a consistent app timezone, and if date ranges are business-critical, add a custom constraint that converts bounds before applying.
Do not build constraint names from user input. Values are bound parameters and are safe, but the column being constrained comes from your registered constraint objects. A saved-query feature that reconstructs constraints from a JSON payload must whitelist against QueryBuilder::getConstraints() — otherwise you have reintroduced mass-assignment for SQL.
Defer vs. live is a deliberate choice. Without ->deferFilters(), each keystroke in a text rule triggers a Livewire request and a full table re-query. On slow tables that feels broken. With deferred filters plus ->persistFiltersInSession(), the tree survives navigation. Pick one consciously and document it for the team.
Debugging checklist. When the wrong rows come back:
->modifyQueryUsing(fn ($query) => dd($query->toSql(), $query->getBindings())) on the filter to see exactly what was generated.DB::enableQueryLog() + DB::getQueryLog() in a Pest test to assert the applied SQL.php artisan about and composer show filament/* before debugging anything else; a half-upgraded Filament set produces bizarre component errors.Extension points worth knowing. Custom Constraint subclasses (columns), custom Rule subclasses (operators), constraint ->form() schemas (input widgets), constraint icons/labels and translations for a polished panel, and the filter's own query-modification hook for invariants that must always hold — e.g. tenant scoping or soft-deleted exclusion — regardless of what the user builds:
QueryBuilder::make()
->modifyQueryUsing(fn (Builder $query) => $query->whereBelongsTo(Filament::auth()->user()->team))
->constraints([...])
Keep invariants here, not in each constraint — otherwise a new constraint added six months later silently bypasses them.
Support reality. With effectively zero independent dependents, you are relying on the Filament core team and its community for fixes. That is fine — Filament is heavily used and actively maintained — but it means filing issues against filamentphp/filament with the full query-builder context, and budgeting for a small internal patch (via a Composer replace/VCS fork) if a blocking bug appears mid-release.
How can I help you explore Laravel packages today?