Skip to content
Bladewell

<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.

PHP
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.

PHP
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.

Blade
<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.

    OWASP: Broken access control · Laravel: Authorization

Server cost

  • Text conditions compare LOWER(column), which a normal index can't serve, and contains reads every row. On large tables index LOWER(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. A using() field with whereHas runs 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:

Terminal
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.

Orders
Order Customer Status Placed Amount
ORD-1001 Farah Aziz Failed Nov 11, 2026 $89.30
ORD-1002 Chen Wei Failed Nov 9, 2026 $160.60
ORD-1003 Hana Sato Refunded Nov 8, 2026 $231.90
ORD-1004 Eli Novak Refunded Nov 6, 2026 $303.20
ORD-1005 Ben Carter Refunded Nov 5, 2026 $374.50
ORD-1006 Gus Holm Pending Nov 3, 2026 $445.80
ORD-1007 Dara Okafor Pending Nov 1, 2026 $517.10
ORD-1008 Ana Silva Pending Oct 31, 2026 $588.40
Show code
Blade
<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.

Which customers

Name
Country
Orders
Joined
Gets the newsletter
is
is not
is less than
is at most
is more than
is at least
is between
is empty
isn't empty

Filter

Name
Country
Orders
Joined
Gets the newsletter
is
is not
is less than
is at most
is more than
is at least
is between
is empty
isn't empty

Enter a whole number.

Filter

3 of 5 customers match.

Show code
Blade
<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
index.blade.php
resources/views/components/widget/filter-builder/part.blade.php Show
part.blade.php
resources/js/widget/filter-builder/index.js Show
index.js
app/View/Widget/Filters.php Show
Filters.php
app/View/Widget/FilterField.php Show
FilterField.php
app/View/Widget/FilterType.php Show
FilterType.php
app/View/Widget/FilterOperator.php Show
FilterOperator.php
app/View/Widget/FilterState.php Show
FilterState.php
app/View/Widget/FilterCondition.php Show
FilterCondition.php
app/View/Widget/FilterCollection.php Show
FilterCollection.php