Filters
Declarative filter bar inspired by Filament and EasyAdmin
Filters add a declarative filter popover to a server-side table, inspired by
Filament and
EasyAdmin. Declare them in configureFilters(); the Stimulus controller renders
a funnel toggle (with an active-count badge) next to the search box, opening a
popover that holds the controls plus Apply filters and Reset actions.
The table reloads over AJAX when filters are applied, and each filter applies a
Doctrine condition server-side.
Filters target server-side (Doctrine) tables. They have no effect on client-side data tables in this version.
Quick start
use Doctrine\ORM\QueryBuilder;
use Pentiminax\UX\DataTables\Filter\{CheckboxFilter, ChoiceFilter, DateRangeFilter, TernaryFilter, TextFilter};
use Pentiminax\UX\DataTables\Model\Filters;
public function configureFilters(Filters $filters): Filters
{
$nameFilter = TextFilter::new('name')->label('Name');
$statusFilter = ChoiceFilter::new('status')
->label('Status')
->options(['Draft' => 'draft', 'Published' => 'published']);
$verifiedFilter = TernaryFilter::new('verified')
->field('emailVerifiedAt')
->trueLabel('Verified')
->falseLabel('Not verified');
$createdAtFilter = DateRangeFilter::new('createdAt')
->label('Created');
$vipFilter = CheckboxFilter::new('vip')
->label('VIP only')
->query(
fn (QueryBuilder $qb, mixed $value, string $alias) => $qb->andWhere("$alias.score > :vip")->setParameter('vip', 100)
);
return $filters
->add($nameFilter)
->add($statusFilter)
->add($verifiedFilter)
->add($createdAtFilter)
->add($vipFilter);
}
Conventions
Every filter is created with ::new(string $name). The name is the AJAX payload
key (filters[name]).
| Method | Description |
|---|---|
label(string) | Control label (defaults to a humanized name) |
field(string) | Doctrine field path (defaults to the name). Supports relations, e.g. author.name |
placeholder(string) | Control placeholder / empty option label |
query(Closure) | Override the default condition: fn (QueryBuilder $qb, mixed $value, string $alias) |
A query() closure fully replaces the default behaviour of the filter type.
Every built-in filter extends AbstractFilter, which implements the
FilterInterface contract: getName(), apply(), jsonSerialize(), and
translateLabels() — called by the rendering preparer so labels and
placeholders are resolved through the translator at render time rather than in
configureFilters(). A custom filter implementing FilterInterface directly
must declare translateLabels() too; an empty body is fine when the filter
exposes no translatable string.
Filter types
TextFilter
Case-insensitive LIKE %value% on the target field.
TextFilter::new('name')->label('Name');
ChoiceFilter
Exact match, or IN (...) when multiple() is enabled. options() accepts the
[label => value] convention, a list of BackedEnum cases, or a BackedEnum
class-string.
ChoiceFilter::new('status')->options(['Draft' => 'draft', 'Published' => 'published']);
ChoiceFilter::new('roles')->multiple()->options(Role::class);
Options from a Doctrine entity
entity() loads the options from the rows of an entity, which suits a filter on an association.
The value defaults to the entity’s id, so the filter compares the association itself:
use App\Entity\Author;
use App\Entity\Category;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Pentiminax\UX\DataTables\Filter\ChoiceFilter;
ChoiceFilter::new('category')
->label('Category')
->entity(Category::class, label: 'name', orderBy: ['name' => 'ASC']);
ChoiceFilter::new('author')
->multiple()
->entity(
class: Author::class,
label: static fn (Author $author): string => $author->getFullName(),
queryBuilder: static function (EntityRepository $repository, QueryBuilder $qb): void {
$qb->andWhere('e.active = true');
},
);
| Argument | Meaning |
|---|---|
class | Entity to load the options from. |
label | Property path or closure giving the option label. See below when omitted. |
value | Property path giving the submitted value. Defaults to id; rows where it is null are skipped. |
orderBy | findBy() ordering, such as ['name' => 'ASC']. |
criteria | findBy() criteria. |
queryBuilder | Closure receiving the repository and a query builder aliased e; replaces criteria and orderBy. |
Without a label, the first non-null of display, name, label and title is used,
then __toString(), then the value itself. options(Category::class) is a shortcut for
entity(Category::class): a class that is not a BackedEnum is read as an entity.
The options are queried each time the table renders, so they follow the data without a cache to clear. Doctrine must manage the class: rendering throws otherwise.
TernaryFilter
Three states (all / true / false). On a boolean field, the true and false states compare the column
with true and false. On any other field, the true state matches field IS NOT NULL and the
false state field IS NULL, which suits a nullable date or any field where “is set” is the question.
Call nullable() to keep the NULL checks on a boolean field (“has a value” / “has no value”), or
values() to compare the field with concrete values. values() wins over nullable().
TernaryFilter::new('active');
TernaryFilter::new('verified')->field('emailVerifiedAt')
->trueLabel('Verified')->falseLabel('Not verified');
TernaryFilter::new('consent')->nullable();
TernaryFilter::new('status')->values('Y', 'N');
DateRangeFilter
Two optional bounds (from / to). from is inclusive (>=). A date-only to on a
datetime column includes the selected calendar day (exclusive next midnight). A
time-bearing to, and any to on a date column, stay inclusive (<=).
DateRangeFilter::new('createdAt')->label('Created');
CheckboxFilter (generic)
A checkbox driven entirely by a query() closure, executed only when checked.
CheckboxFilter::new('vip')->query(
fn (QueryBuilder $qb, mixed $value, string $alias) =>
$qb->andWhere("$alias.score > :vip")->setParameter('vip', 100)
);
Declaring filters with an attribute
#[DataTableFilter] declares the same filters without writing configureFilters(). It goes on a
property, a getter, or the table class, and its signature matches
#[DataTableColumn] exactly.
use App\Enum\Status;
use Pentiminax\UX\DataTables\Attribute\DataTableFilter;
class User
{
#[DataTableFilter(options: ['label' => 'Name'])]
private string $name;
#[DataTableFilter]
private Status $status;
#[DataTableFilter(options: ['label' => 'Created'])]
private \DateTimeImmutable $createdAt;
}
Arguments:
| Argument | Meaning |
|---|---|
type | Filter class to build. Guessed from the declared type when omitted. |
options | Applied through the filter class own fluent API. |
name | Filter key. Defaults to the member name, required on a class-level declaration. |
position | Explicit ordering. Ties fall back to declaration order. |
Guessed types
| Declared type | Filter |
|---|---|
bool | TernaryFilter |
A BackedEnum | ChoiceFilter, with its options filled from the enum cases |
A DateTimeInterface | DateRangeFilter |
int or float | Rejected: no filter class queries a number |
| Anything else, or no type | TextFilter |
A number is rejected rather than guessed. TextFilter is the only fallback and it skips numeric
Doctrine fields, so the guess would render a control that filters nothing. Name a filter class on
the attribute, or point the filter at a text field, and the declaration raises while the container
compiles instead of failing silently on the first request.
A TernaryFilter on a boolean column compares the column value, whether it is guessed from a bool
property or named on the attribute, so “true” and “false” each match the rows they name. On any
other column it keeps the IS NULL / IS NOT NULL checks. A ChoiceFilter is filled from the enum
either way, since an explicit options entry still wins.
CheckboxFilter is never guessed and cannot be declared: it has no default condition, so it only
works with the query() closure that no attribute can carry. Declaring it raises when the container
compiles.
Options
Option keys resolve against the filter that carries them: the applier tries setOption(), then
option(), then a short alias table. Every fluent method of a filter class is therefore reachable,
including on a filter you wrote yourself. An option no method answers raises an exception naming the
option and the filter class.
use App\Entity\Author;
use App\Enum\Role;
use Pentiminax\UX\DataTables\Attribute\DataTableFilter;
use Pentiminax\UX\DataTables\Filter\ChoiceFilter;
#[DataTableFilter(options: [
'label' => 'Author',
'field' => 'author.name',
'placeholder' => 'Search an author',
])]
private Author $author;
#[DataTableFilter(ChoiceFilter::class, ['options' => Role::class, 'multiple' => true])]
private array $roles;
One key does not follow either convention:
| Option | Effect |
|---|---|
values | Calls values($trueValue, $falseValue) on a ternary filter, in that order. |
query() stays out of reach, since PHP rejects a closure as a constant attribute argument. A filter
that needs one belongs in configureFilters().
Resolution order
Filters follow the same chain as columns, and the first level that declares anything wins:
configureFilters()#[DataTableFilter]on the table class#[DataTableFilter]on the data class
A table that declares one filter in configureFilters() therefore gets exactly that one, not a
merge. Move every filter into the attribute, or keep them all in configureFilters().
Validation
Filter declarations are read while the container compiles, so an unknown option, a type that is not a filter, and a duplicated filter name fail the build rather than the first request that opens the table.
Validation follows the resolution order: a table that overrides configureFilters(), or that
declares filters on its own class, reads no filter attribute from the data class, so those
declarations are not validated either. A table overriding configureFilters() only to return an
empty collection in some branch therefore gets the error on the first request instead.
How it works
configureFilters()builds aFilterscollection, serialized into the Stimulusviewpayload under thefilterskey.- The controller registers a custom DataTables
filtersfeature and places it in thelayout— by default intopEnd, right after the built-insearchbox. Clicking the funnel toggle opens the popover; applying merges the committed values intoajax.dataasfilters[name], preserving any existing static AJAX data. - On Apply filters (or Reset), the table reloads. Server-side,
DataTableRequest::filterscarries the submitted values and each filter applies its condition after the standard search/order chain, so both the filtered count and the page reflect the filters.
Empty or irrelevant values are ignored (no-op).
Localizing the filter bar
The filter bar chrome strings — the Filters toggle, the Reset and
Apply filters buttons, and the All placeholder of empty select filters —
are translated automatically based on the active locale. The bundle ships a
DataTables translation domain (English and French out of the box); add a
DataTables.<locale>.xlf catalog in your app to cover more languages.
Override any string per table with Filters::labels(). Values may be plain
strings or your own translation keys (resolved in the default domain):
protected function configureFilters(Filters $filters): void
{
$filters
->labels(
title: 'datatable.filters', // translation key or literal
reset: 'datatable.reset',
apply: 'datatable.apply',
all: 'datatable.all', // the "All" placeholder
)
->add(ChoiceFilter::new('status')->options(Status::class));
}
Any argument left out keeps the bundle’s built-in localized default. To change a
single placeholder for one filter only, use placeholder() on that filter — it
takes precedence over the shared all label.
Filter label() and placeholder() follow the same rule as column titles: pass
a literal or a translation key. Keys are resolved in the default domain at
render time. Omit label() to keep the humanized property name (not translated).
DateRangeFilter::new('lastLoginAt')
->label('user.last_login')
->placeholder('user.last_login.placeholder');
TernaryFilter::trueLabel() / falseLabel() and ChoiceFilter options backed
by a TranslatableInterface enum are translated the same way.
Reset button in the header
showHeaderResetButton() adds a Reset button next to the funnel toggle, so the user can clear
every filter without opening the popover. It only shows while at least one filter is applied, and
reuses the reset label.
protected function configureFilters(Filters $filters): Filters
{
return $filters
->showHeaderResetButton()
->add(ChoiceFilter::new('status')->options(Status::class));
}
The first setting found wins: Filters::showHeaderResetButton(), then
DataTable::showHeaderResetButton(), then the data_tables.options.showHeaderResetButton
configuration key. The button is off
otherwise.
Remembering applied filters
With stateSave() enabled on the table, the applied filter values are saved alongside the rest of
the DataTables state and restored when the table loads again. A restored value is checked against
the current filter declarations: a filter that no longer exists, or a choice that is no longer
offered, is dropped rather than submitted.
public function configureDataTable(DataTable $table): DataTable
{
return $table->serverSide(true)->stateSave();
}
Positioning the filter button
By default the funnel toggle sits in topEnd, just after the search box. To put
it elsewhere, reference the filters feature in the table layout with
Feature::FILTERS; the controller injects the live filter instance at that
position instead of appending it:
use Pentiminax\UX\DataTables\Enum\Feature;
$dataTable->layout([
'topStart' => [Feature::FILTERS, Feature::PAGE_LENGTH],
'topEnd' => Feature::SEARCH,
]);