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
$dataTable->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 — 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 work and match a complete identifier. empty and notEmpty only
test IS NULL / IS NOT NULL on those columns — they must not compare the identifier to an
empty string. 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'). Pass a target to replace the defaults with that single group.
// Per-column search inputs in the table footer instead of the second header row.
$dataTable->columnControl(target: 'tfoot');
// A specific content type in a header row.
$dataTable->columnControl(target: 1, content: ['searchText']);
ColumnControl creates the targeted row when the table does not render one, so a footer target
needs no <tfoot> markup — render_datatable() emits an empty <table> and every row is built
at initialization.
Naming a target drops the default order controls. Keep both by building the extension directly:
use Pentiminax\UX\DataTables\Model\Extensions\ColumnControlExtension;
$dataTable->addExtension(
(new ColumnControlExtension([]))
->add(0, ['order'])
->add('tfoot', ['search'])
);
new ColumnControlExtension() keeps the defaults, new ColumnControlExtension([]) starts from
nothing, and add() appends one group per call in call order.
content needs a target to be placed in: columnControl(content: ['searchText']) throws rather
than silently keeping the defaults.
Per-column Overrides
Override the control content for a single column with setColumnControl(), using the same content
descriptors as the DataTables columns.columnControl option:
use Pentiminax\UX\DataTables\Column\TextColumn;
$dataTable->columnControl();
TextColumn::new('name', 'Name')
->setColumnControl(['colvisDropdown']);
setColumnControl() takes precedence over disableColumnControl() regardless of call order. Both
require ColumnControlExtension to be added to the table — the frontend only loads the
ColumnControl plugin bundle when the table-level extension is present, so a column-level override
or a disabled column stays inert without it.
Overriding The Actions Column
The auto-generated actions column is built internally and has ColumnControl disabled by default, so
setColumnControl() on AbstractColumn doesn’t reach it. Use Actions::setColumnControl() in
configureActions() instead — it flows through to the generated column the same way
setColumnClassName() already does:
use Pentiminax\UX\DataTables\Model\Actions;
public function configureActions(Actions $actions): Actions
{
return $actions
->setColumnControl(['colvisDropdown'])
->add(Action::edit());
}
Custom Content Types
ColumnControl resolves the content descriptors above against DataTable.ColumnControl.content, a
plugin registry (see the DataTables.net docs)
— registering a custom type means adding to that object before the table is constructed. Neither
existing controller event fits: datatables:pre-connect fires before ColumnControl’s plugin bundle
has loaded, so DataTable.ColumnControl doesn’t exist yet; datatables:connect fires after the
table is already constructed, which is too late — an unregistered content type makes construction
throw. Use datatables:pre-init, which fires once extension plugins have loaded but before the
table is built:
// assets/controllers/mytable_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
},
}
}
}
Then reference the type by name from PHP, same as any built-in content descriptor:
TextColumn::new('rating', 'Rating')
->setColumnControl(['myRating']);
Registering onto DataTable.ColumnControl.content only needs to happen once per page, not once
per table — a listener attached to one table’s controller instance still runs before that table’s
own construction, which is all a plugin registration needs.
Frequent Pitfalls
- Calling
setColumnControl()ordisableColumnControl()without also enablingColumnControlExtensionon the table ($table->columnControl()); the column-level setting is silently inert because the frontend never loads the plugin bundle. - Enabling it without ensuring the target columns are searchable/orderable.
- Passing a target and expecting the default order controls to stay — they are replaced. Add them
back with
add(0, ['order']). - Expecting partial search (
contains,starts) to work on a UUID or ULID column. - Registering a custom content type on
datatables:pre-connectinstead ofdatatables:pre-init—pre-connectfires before the DataTable library and its extensions have even loaded, so its event detail has noDataTablereference to register against. The table then throwsUnknown ColumnControl content typeonce it’s constructed.