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

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

Documentation

Column Control Extension

Add per-column sorting and searching controls

When To Use

Use Column Control when users need direct per-column order and filter controls in the table UI.

Minimal Example

Enable the extension in configureDataTable(), the hook that owns table-wide options:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl();
    }
}

Behavior

The bundled ColumnControlExtension serializes default controls for:

  • ordering actions (asc, desc, add/remove order)
  • per-column search input

Search logics that rely on SQL LIKE, including contains, notContains, starts, and ends, are ignored on columns mapped to a native identifier type (guid, uuid, ulid, uuid_binary, uuid_binary_ordered_time), because PostgreSQL and SQL Server reject LIKE on those columns. equal and notEqual still match a complete identifier. empty and notEmpty test only IS NULL / IS NOT NULL. See Searching UUID and ULID columns.

Choosing Where The Controls Go

Each control group targets one row: a header row index (0, 1, …) or a tfoot string ('tfoot', 'tfoot:1'). Passing a target replaces the defaults with one group. ColumnControl creates the target row, so the rendered table needs no <tfoot> markup:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl(target: 'tfoot', content: ['searchText']);
    }
}

Build ColumnControlExtension directly when the table needs several groups:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControlExtension;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->addExtension(
            (new ColumnControlExtension([]))
                ->add(0, ['order'])
                ->add('tfoot', ['search'])
        );
    }
}

new ColumnControlExtension() keeps the defaults. new ColumnControlExtension([]) starts from nothing, and add() appends groups in call order. Passing content without a target throws because the bundle cannot know which row should contain it.

Per-column Overrides

Enable the extension in configureDataTable(), then put an override on the column returned by configureColumns():

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureColumns(): iterable
    {
        yield TextColumn::new('name', 'Name')
            ->setColumnControl(['colvisDropdown']);

        yield TextColumn::new('office', 'Office');
    }

    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl();
    }
}

setColumnControl() takes precedence over disableColumnControl() regardless of call order. Both need the table-level extension because that is what loads the ColumnControl frontend bundle.

Search Lists

Use the typed SearchList descriptor when a column has a finite set of searchable values.

Local Options

With no static options or Ajax provider, ColumnControl derives unique values from the rows loaded in the browser. This works for client-side tables, where the browser has the complete dataset:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControl\SearchList;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl(target: 1, content: [SearchList::new()]);
    }
}

A server-side table only has one page in the browser, so it needs static options or options returned with the Ajax response.

Specific Columns

Keep typed search inputs on most columns and replace them with lists on selected columns:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControl\SearchList;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureColumns(): iterable
    {
        yield TextColumn::new('name', 'Name');

        yield TextColumn::new('office', 'Office')->setColumnControl([
            [
                'target'  => 1,
                'content' => [SearchList::new()],
            ],
        ]);
    }

    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl(target: 1, content: ['search']);
    }
}

A list and typed input can also share one dropdown:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControl\SearchList;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl(
            target: 1,
            content: [['search', SearchList::new()]],
        );
    }
}

Static and Enum Options

options() accepts simple values, DataTables label / value arrays, a [label => value] map, a list of BackedEnum cases, or a backed enum class:

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControl\SearchList;

enum EmploymentStatus: string
{
    case Active = 'active';
    case Inactive = 'inactive';

    public function getLabel(): string
    {
        return match ($this) {
            self::Active => 'Active employee',
            self::Inactive => 'Former employee',
        };
    }
}

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureColumns(): iterable
    {
        yield TextColumn::new('name', 'Name');

        yield TextColumn::new('status', 'Status')->setColumnControl([
            [
                'target'  => 1,
                'content' => [SearchList::new()->options(EmploymentStatus::class)],
            ],
        ]);
    }

    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl();
    }
}

An enum implementing TranslatableInterface uses Symfony’s translator when one is available. Otherwise, labels fall back to getLabel(), then label(), then the case name. Backed values keep their string or integer type.

Options from the Ajax Response

For dynamic options, use a closure or an application service implementing SearchListOptionsProviderInterface. The provider receives the normalized request and each permitted, searchable column. The bundle returns its options under columnControl.<columnName>:

use App\Entity\Employee;
use App\Repository\OfficeRepository;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Contracts\ColumnInterface;
use Pentiminax\UX\DataTables\Contracts\SearchListOptionsProviderInterface;
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControl\SearchList;

final class OfficeOptionsProvider implements SearchListOptionsProviderInterface
{
    public function __construct(private readonly OfficeRepository $offices)
    {
    }

    public function provide(DataTableRequest $request, ColumnInterface $column): ?iterable
    {
        if ('office' !== $column->getName()) {
            return null;
        }

        return $this->offices->labelValueOptions();
    }
}

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function __construct(private readonly OfficeOptionsProvider $officeOptions)
    {
    }

    public function configureDataTable(DataTable $table): DataTable
    {
        return $table
            ->serverSide()
            ->processing()
            ->columnControl(
                target: 1,
                content: [SearchList::new()->ajaxOptionsProvider($this->officeOptions)],
            );
    }
}

The provider decides whether active filters affect its options and must apply the same tenant and authorization scope as the table query. Returning null omits the column. Returning an empty iterable publishes an empty list. Static options and an Ajax provider cannot be combined on one descriptor because DataTables gives static options priority.

Dynamic providers run for the bundle’s Ajax endpoint and manual endpoints that call AbstractDataTable::getResponse(). API Platform and external Ajax endpoints must add their own columnControl response object.

Display Options

The descriptor exposes the main searchList settings but omits values that were not explicitly configured, leaving their defaults to the installed ColumnControl version:

use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControl\SearchList;

final class EmployeeDataTable extends AbstractDataTable
{
    public function configureColumns(): iterable
    {
        yield TextColumn::new('name', 'Name');
        yield TextColumn::new('office', 'Office');
    }

    public function configureDataTable(DataTable $table): DataTable
    {
        return $table
            ->ajax('/employees.json')
            ->columnControl(
                target: 1,
                content: [
                    SearchList::new()
                        ->ajaxOnly(false)
                        ->hidable(false)
                        ->orthogonal('filter')
                        ->search(true)
                        ->select(true)
                        ->title('Choose [title]'),
                ],
            );
    }
}

ajaxOnly(false) lets a client-side Ajax table derive local options when its response omits a column. A server-side table never derives a complete list from the current page. orthogonal() only changes labels derived from local rows. [title] is replaced with the column title.

With hidable(false), the bundle keeps an empty columnControl object when every provider returns null, so ColumnControl leaves the empty control visible.

State saving, scrolling, FixedHeader, and FixedColumns use ColumnControl’s native behavior.

Overriding The Actions Column

The generated actions column has ColumnControl disabled by default. Configure it through configureActions(), because it is not one of the columns returned by configureColumns():

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\Action;
use Pentiminax\UX\DataTables\Model\Actions;
use Pentiminax\UX\DataTables\Model\DataTable;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl();
    }

    public function configureActions(Actions $actions): Actions
    {
        return $actions
            ->setColumnControl(['colvisDropdown'])
            ->add(Action::edit());
    }
}

Custom Content Types

ColumnControl resolves content descriptors against DataTable.ColumnControl.content. Register a custom type on datatables:pre-init, after the plugin loads but before DataTables constructs the table:

// assets/controllers/employee_table_controller.js
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  connect() {
    this.element.addEventListener('datatables:pre-init', this._onPreInit)
  }

  disconnect() {
    this.element.removeEventListener('datatables:pre-init', this._onPreInit)
  }

  _onPreInit(event) {
    const { DataTable } = event.detail

    DataTable.ColumnControl.content.myRating = {
      defaults: { min: 1, max: 5 },
      init(config) {
        const el = document.createElement('input')
        el.type = 'number'
        el.min = String(config.min)
        el.max = String(config.max)

        el.addEventListener('change', () => {
          this.dt().column(this.idx()).search(el.value).draw()
        })

        return el
      },
    }
  }
}

Reference that content name from configureColumns() and enable the extension in configureDataTable():

use App\Entity\Employee;
use Pentiminax\UX\DataTables\Attribute\AsDataTable;
use Pentiminax\UX\DataTables\Column\NumberColumn;
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Model\AbstractDataTable;
use Pentiminax\UX\DataTables\Model\DataTable;

#[AsDataTable(Employee::class)]
final class EmployeeDataTable extends AbstractDataTable
{
    public function configureColumns(): iterable
    {
        yield TextColumn::new('name', 'Name');
        yield NumberColumn::new('rating', 'Rating')
            ->setColumnControl(['myRating']);
    }

    public function configureDataTable(DataTable $table): DataTable
    {
        return $table->columnControl();
    }
}

Registering the content once per page is enough. datatables:pre-connect is too early because the plugin has not loaded, and datatables:connect is too late because construction has finished.

Frequent Pitfalls

  • Calling setColumnControl() or disableColumnControl() without enabling ColumnControl in configureDataTable().
  • Enabling controls on columns that are not searchable or orderable.
  • Passing a target and expecting default order controls to stay. Add every required group through ColumnControlExtension.
  • Expecting partial UUID or ULID searches to work.
  • Returning provider options without applying the table’s tenant and authorization scope.
  • Registering custom content on datatables:pre-connect instead of datatables:pre-init.