<x-widget.filter-builder>
Filter builder Blade component for Laravel and Livewire
Lets people build their own filters: rows of a field, a condition and a value, matching all or any. Conditions follow the field's type: contains or starts with for text, more than or between for numbers, before, between or in the last N days for dates, any of for choices. Sent as one field, so it filters a table in place and keeps a shareable link. Comes with Filters, which reads the request against your own list of fields, turns down anything else with the reason, and applies it to a query or collection with every value bound. Editing the filters needs JavaScript; without it, the current ones show and are sent unchanged.
php artisan bladewell:add filter-builder
- Also adds
- Repeater, Select, Text input, Number, Date picker
- Works with
- Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS 4.1+, PHP 8.3+
Usage
In a controller
Define the fields once and use the same Filters for the page and the query: only these can be filtered, by the conditions each type offers, with every value checked and bound. A field's key is what the URL carries; column() names what it filters, and using() filters a relation or a computed value yourself.
use App\View\Widget\FilterField;
use App\View\Widget\FilterOperator;
use App\View\Widget\Filters;
$filters = Filters::make([
FilterField::text('number', 'Order number'),
FilterField::choice('status', 'Status', ['paid' => 'Paid', 'pending' => 'Pending', 'refunded' => 'Refunded']),
FilterField::dateTime('placed', 'Placed')->column('created_at'),
FilterField::number('amount', 'Amount')->column('total'),
// Every condition a text field offers, on the customer's name. The negative ones (doesn't contain, is not, is empty)
// keep orders with no such customer, as a field of the order's own keeps rows with nothing in it.
FilterField::text('customer', 'Customer')->using(function ($query, FilterOperator $operator, ?string $value) {
$text = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], (string) $value);
$like = "LOWER(name) LIKE LOWER(?) ESCAPE '!'";
[$sql, $binding] = match ($operator) {
FilterOperator::Contains, FilterOperator::NotContains => [$like, "%{$text}%"],
FilterOperator::StartsWith => [$like, "{$text}%"],
FilterOperator::EndsWith => [$like, "%{$text}"],
FilterOperator::Is, FilterOperator::IsNot => ['LOWER(name) = LOWER(?)', (string) $value],
// Is empty and isn't empty: whether there's a customer with a name.
default => ["COALESCE(name, '') <> ?", ''],
};
$has = ! in_array($operator, [FilterOperator::NotContains, FilterOperator::IsNot, FilterOperator::Empty], true);
return $query->{$has ? 'whereHas' : 'whereDoesntHave'}('customer', fn ($customer) => $customer->whereRaw($sql, [$binding]));
}),
]);
// In the person's time zone, so "placed on 11 November" is their 11 November.
$state = $filters->read($request->query('filters'), timezone: $request->user()->timezone);
// On a query already limited to what they may see: the filters only ever narrow it.
$orders = $filters->apply($request->user()->orders()->getQuery(), $state)
->latest()
->paginate(20)
->withQueryString();
// The view needs only the state: <x-widget.filter-builder :state="$state" />. It brings the fields with it.
return view('orders.index', ['orders' => $orders, 'state' => $state]);
Livewire component
In a Livewire component, keep the filters in a #[Url] string property: the builder writes its JSON there, the address bar keeps it for reloads and links, and read() checks it on every render, as it would a request.
use App\View\Widget\FilterField;
use App\View\Widget\Filters;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Orders extends Component
{
use WithPagination;
#[Url]
public string $filters = '';
public function updatedFilters(): void
{
$this->resetPage();
}
public function render()
{
$fields = Filters::make([
FilterField::text('number', 'Order number'),
FilterField::choice('status', 'Status', ['paid' => 'Paid', 'pending' => 'Pending', 'refunded' => 'Refunded']),
FilterField::dateTime('placed', 'Placed')->column('created_at'),
FilterField::number('amount', 'Amount')->column('total'),
]);
// The browser can set the property to anything: read() keeps only what the fields allow.
$state = $fields->read($this->filters, timezone: auth()->user()->timezone);
return view('livewire.orders', [
'state' => $state,
'orders' => $fields->apply(auth()->user()->orders()->getQuery(), $state)->latest()->paginate(20),
]);
}
}
Livewire view
Its view: wire:model.live binds the builder's one field to the property, so each completed row re-renders the table. The rows themselves are the browser's (wire:ignore), so a render never resets what's being typed.
<x-widget.filter-builder :state="$state" wire:model.live="filters" label="Filter orders" />
<x-widget.table caption="Orders" :rows="$orders" :columns="[['label' => 'Order'], ['label' => 'Status'], ['label' => 'Amount', 'align' => 'end']]">
@foreach ($orders as $order)
<x-widget.table.row>
<td>{{ $order->number }}</td>
<td>{{ $order->status }}</td>
<td>{{ $order->total }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
On the server
The filter builder can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.
Security
-
Every field you offer can be worked out, even if it isn't shown: "salary is more than 50000", then 75000, narrows anyone's salary down. Offer only fields the person may already see, and build the list per person (Salary for admins only). Apply the filters with
Filters::apply()on a query already limited to what they may see (their own orders), never as the only constraint.
Server cost
-
Text conditions compare
LOWER(column), which a normal index can't serve, and contains reads every row. On large tables indexLOWER(column)(an expression index, in PostgreSQL and MySQL 8), or use full-text search for words. Index the columns your number, date and choice fields use, keep the list short, and up to 10 conditions are read from a request. Ausing()field withwhereHasruns a subquery per row.PostgreSQL: Indexes on expressions · MySQL: Functional key parts · Laravel: Scout
Examples
These examples also use Table, which Filter builder doesn't need on its own. To use them as they are:
php artisan bladewell:add filter-builder table
Orders table
In a table's filters slot, the table filters in place as rows are completed, and the URL keeps them, so a reload or a shared link shows the same orders. Pick a field, then a condition its type offers: contains for text, more than or between for amounts, before or in the last N days for dates, any of for a status. Filters::read() checks the request against the same list of fields and says why it turned anything down; the Usage shows it on an Eloquent query.
Pass
$orders and
$state
from your controller.
Show code Hide code
<x-widget.table
id="filtered-orders"
caption="Orders"
:rows="$orders"
pagination="numbers"
empty="No orders match these filters"
:columns="[
['label' => 'Order'],
['label' => 'Customer'],
['label' => 'Status'],
['label' => 'Placed'],
['label' => 'Amount', 'align' => 'end'],
]"
>
<x-slot:filters>
<x-widget.filter-builder :state="$state" name="order_filters" label="Filter orders" />
</x-slot:filters>
@foreach ($orders as $order)
<x-widget.table.row>
<td>{{ $order['number'] }}</td>
<td>{{ $order['customer'] }}</td>
<td>{{ $order['status_label'] }}</td>
<td>{{ $order['placed_label'] }}</td>
<td>{{ $order['amount_label'] }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
Report
In any GET form: the filters go with the form as one field, for a report, an export or a saved search. A condition the server turned down comes back on its row with the reason, and only the ones it accepted are used.
Pass
$state,
$matching and
$total
from your controller.
Show code Hide code
<form method="GET" class="grid max-w-3xl gap-6">
<x-widget.filter-builder :state="$state" name="report_filters" label="Which customers" add-label="Add condition" />
<div class="flex flex-wrap items-center gap-4">
<x-widget.button type="submit">Show customers</x-widget.button>
<p class="text-foreground/70 text-sm">{{ $matching }} of {{ $total }} customers match.</p>
</div>
</form>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.filter-builder>
| Prop | Default | Description |
|---|---|---|
| state | Required | What Filters::read() made of the request. It brings your fields with it, so the builder only ever offers what the server accepts, and the rows to show back, with the reason next to any it turned down. Before any request: $filters->read(null). |
| name |
'filters'
|
The name the filters are sent under, as one field: ?filters={"match":"all","rules":[…]}. Left out when there are none. |
| label |
'Filters'
|
Above the filters, and their name for screen readers. |
| add-label |
'Add filter'
|
The add button's text. |
| empty-label |
'No filters yet, so every row is shown.'
|
Shown while there are no filters, so the add button reads as the next step. |
| row-label |
'Filter'
|
What a filter row is called, numbered after it: "Remove Filter 2". |
| remove-label |
'Remove'
|
The remove button's name, followed by the row's. |
| match-label |
'Show rows that match'
|
The label of the choice between matching all filters or any. |
| all-label |
'All filters'
|
With matchLabel: matching every filter. |
| any-label |
'Any filter'
|
With matchLabel: matching at least one. |
| field-label |
'Field'
|
The field select's label. |
| condition-label |
'Condition'
|
The condition select's label. |
| value-label |
'Value'
|
The value's label. |
| and-label |
'and'
|
The second value's label, with between. |
| days-label |
'Days'
|
The label of the number of days, with "is in the last … days". |
| changed-message |
'Conditions for :field'
|
Read out when a row's field changes and its conditions change with it. :field is the field's label. |
| left-out-message |
':count filter was left out: see the marked field.|:count filters were left out: see the marked fields.'
|
Above the filters when the server left some out, so people know the rows shown aren't filtered by them. One form, then the other after |, chosen by :count. |
| pending-message |
':count filter isn\'t complete, so it isn\'t used yet.|:count filters aren\'t complete, so they aren\'t used yet.'
|
Under the filters while some rows are still missing a value, or have one the server would turn down: those aren't used, so the rows shown aren't filtered by them yet. One form, then the other after |, chosen by :count. |
<x-widget.filter-builder.part>
| Prop | Default | Description |
|---|---|---|
| kind | Required | Which control: operator:{type} (the conditions a type offers), text, number, number-to, whole, whole-to, date, date-to, days, or choice:{field key}. |
| field |
null
|
With choice: the field, whose options it lists. |
| value |
null
|
What it holds: a condition, a value, or with choice a list of them. |
| error |
null
|
Why the server turned the row down, when it's about this control: shown on it, as on any field. |
Accessibility
All 2 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What bladewell:add filter-builder writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/filter-builder/index.blade.php Show
resources/views/components/widget/filter-builder/part.blade.php Show
resources/js/widget/filter-builder/index.js Show
app/View/Widget/Filters.php Show
app/View/Widget/FilterField.php Show
app/View/Widget/FilterType.php Show
app/View/Widget/FilterOperator.php Show
app/View/Widget/FilterState.php Show
app/View/Widget/FilterCondition.php Show
app/View/Widget/FilterCollection.php Show