Type to search columns, filters, options, and extensions.

↑↓ to navigate · Enter to open · Esc to close

Documentation

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

MethodDescription
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');
        },
    );
ArgumentMeaning
classEntity to load the options from.
labelProperty path or closure giving the option label. See below when omitted.
valueProperty path giving the submitted value. Defaults to id; rows where it is null are skipped.
orderByfindBy() ordering, such as ['name' => 'ASC'].
criteriafindBy() criteria.
queryBuilderClosure 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:

ArgumentMeaning
typeFilter class to build. Guessed from the declared type when omitted.
optionsApplied through the filter class own fluent API.
nameFilter key. Defaults to the member name, required on a class-level declaration.
positionExplicit ordering. Ties fall back to declaration order.

Guessed types

Declared typeFilter
boolTernaryFilter
A BackedEnumChoiceFilter, with its options filled from the enum cases
A DateTimeInterfaceDateRangeFilter
int or floatRejected: no filter class queries a number
Anything else, or no typeTextFilter

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:

OptionEffect
valuesCalls 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:

  1. configureFilters()
  2. #[DataTableFilter] on the table class
  3. #[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

  1. configureFilters() builds a Filters collection, serialized into the Stimulus view payload under the filters key.
  2. The controller registers a custom DataTables filters feature and places it in the layout — by default in topEnd, right after the built-in search box. Clicking the funnel toggle opens the popover; applying merges the committed values into ajax.data as filters[name], preserving any existing static AJAX data.
  3. On Apply filters (or Reset), the table reloads. Server-side, DataTableRequest::filters carries 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,
]);