Columns Overview
Column system overview — factory pattern, inherited methods, types, and how to pick the right column
Each column in a DataTable is a PHP object that configures how a field is displayed, sorted, searched, and exported. All column classes extend AbstractColumn, which provides a common set of fluent methods.
Creating a Column
Use the static ::new() factory on the concrete column class:
use Pentiminax\UX\DataTables\Column\TextColumn;
use Pentiminax\UX\DataTables\Column\NumberColumn;
use Pentiminax\UX\DataTables\Column\DateColumn;
TextColumn::new('firstName', 'First Name');
NumberColumn::new('age', 'Age');
DateColumn::new('createdAt', 'Created');
The first argument is the data key (must match your mapRow() output), the second is the display header.
Choosing a Column Type
| Column | Use when… |
|---|---|
| TextColumn | Plain text or HTML values |
| NumberColumn | Integers and floats |
| MoneyColumn | Currency amounts (ISO 4217, cents storage) |
| DateColumn | Date/datetime strings |
| BooleanColumn | True/false toggles with optional AJAX switch |
| ChoiceColumn | Finite value sets (statuses, enum variants) |
| EmailColumn | Email addresses as clickable mailto links |
| ImageColumn | Image URLs rendered as <img> thumbnails |
| IconColumn | A Lucide icon representing a status, boolean, or enum |
| UrlColumn | Arbitrary links from raw URLs, routes, or callables |
| TemplateColumn | Custom server-side Twig rendering |
| ActionColumn | Row action buttons (edit, delete, detail) |
Inherited Methods (AbstractColumn)
All column types inherit these methods.
Display
$column
->setTitle('Full Name') // Header label
->setClassName('text-primary') // CSS class on each cell
->setWidth('200px') // Column width
->setResponsivePriority(1) // Stay visible longer with Responsive
->setVisible(false) // Hide the column
->setDefaultContent('N/A'); // Fallback for null values
Data
$column
->setData('user.name') // Nested data path for inline rows
->setField('product.ref'); // Nested property path for entity rows
Sorting & Searching
$column
->setOrderable(false) // Disable sorting
->setSearchable(false) // Disable column search
->disableGlobalSearch() // Exclude from global search
->setSearchNormalization(false); // Bare LIKE on the raw column, no LOWER()
Search normalization
Server-side text search lowercases both the column and the search term, so it behaves the same on
PostgreSQL, on MySQL, and on binary collations. setSearchNormalization(false) opts a single
column out of that and compares the raw column with a bare LIKE instead, which is what keeps a
starts search usable on a MySQL prefix index. The bare LIKE follows the column’s collation, so
it is case-sensitive only where the collation is. It applies to global search, per-column search,
and the ColumnControl contains, starts, ends, and notContains logics.
TextColumn::new('reference', 'Reference')
->setSearchNormalization(false);
AbstractColumn carries the flag. A class implementing ColumnInterface directly is normalized
unless it also implements NormalizedSearchColumnInterface and returns false from
isSearchNormalized().
Sorting on a custom expression
By default, server-side ordering targets <alias>.<field>. For a computed column — one
backed by a SELECT alias instead of a mapped entity field — that resolution fails
(has no field or association named …). Use setOrderExpression() to provide the raw DQL
expression or SELECT alias used verbatim in the ORDER BY, bypassing the default resolution:
NumberColumn::new('invoiceCount', 'Invoices')
->setOrderExpression('invoiceCount') // a SELECT alias added in customizeQueryBuilder()
->setSearchable(false)
->disableGlobalSearch();
See Sorting computed columns
for the matching customizeQueryBuilder() setup.
Searching on a different field
Server-side search targets the column’s own field, which fails the same way for a virtual
column — one whose value is assembled in mapRow() rather than mapped on the entity. Such a
column is skipped by the search rather than breaking the query, so its search box silently
returns nothing until you point it at real data.
setSearchField() does that. It affects search only: the rendered value, the row mapping, the
form mapping, the ordering and the client payload all keep using setField().
TextColumn::new('donorProviderName', 'Donor')
->setSearchField('donorProvider.name'); // LEFT JOINs e.donorProvider automatically
The path uses the same dot-notation as setField(), so a relation path is resolved through a
LEFT JOIN. An embeddable field (address.city) stays on the owning alias: it is a mapped
field, not an association, and joining it makes Doctrine reject the query. Use addSearchJoin()
when you need the alias yourself, a WITH condition, or the relation is already joined under a
custom alias in customizeQueryBuilder():
TextColumn::new('donorProviderName', 'Donor')
->addSearchJoin('e.donorProvider', 'dp')
->setSearchField('dp.name');
Joins are applied once: declaring one whose alias is already on the query builder is a no-op, so the same column configuration is safe across global search, column search, and ColumnControl search in a single request.
Searching with a custom predicate
For what a field path cannot express — several fields at once, an EXISTS subquery, a database
function — build the condition yourself with setSearchPredicate():
use Doctrine\ORM\QueryBuilder;
TextColumn::new('donorProviderName', 'Donor')
->addSearchJoin('e.donorProvider', 'dp')
->setSearchPredicate(function (QueryBuilder $qb, string $alias, string $value, string $paramName): string {
$qb->setParameter($paramName, '%'.mb_strtolower($value).'%');
return "LOWER(dp.name) LIKE :{$paramName} OR LOWER(dp.legalName) LIKE :{$paramName}";
});
The closure receives the query builder, the root alias, the raw search term, and a parameter name
unique to that column and search. Bind your parameters on the query builder — under $paramName
or names derived from it — and return a DQL condition instead of calling andWhere(): global
search combines the returned conditions with OR, a column search with AND. Return null to
skip the column for that term.
Overriding buildSearchPredicate() in a column subclass is the class-level equivalent. All three
settings come from Contracts\SearchableColumnInterface, which AbstractColumn implements — so
every bundled column type has them, and a custom class implementing ColumnInterface directly opts
in by implementing that interface too.
:::caution The predicate string is used verbatim in the DQL. Build it from your own code, never by concatenating the search value — bind the value as a parameter, as above. :::
Custom predicates apply to global search, per-column search, and the ColumnControl contains
logic. The ColumnControl list, comparison, and empty/not-empty logics keep their own predicate
shapes; they honor setSearchField() and addSearchJoin() but not setSearchPredicate(), and
they skip a column whose search field the root entity does not map.
Cell Type
$column->setCellType('th'); // Use <th> instead of <td>
Custom JS Rendering
Define arbitrary DataTables render callbacks client-side with the datatables:pre-connect
event:
this.element.addEventListener('datatables:pre-connect', (event) => {
event.detail.config.columns[0].render = (data) => `<strong>${data}</strong>`
})
Formatting Values on the Server
formatValueUsing() transforms a cell value on the server, before it reaches the browser or an
export file:
use Pentiminax\UX\DataTables\Column\TextColumn;
TextColumn::new('customer')
->setField('customer.lastName')
->formatValueUsing(static fn (?string $value, Order $order): string => \sprintf(
'%s %s.',
$order->getCustomer()->getFirstName(),
mb_substr($value ?? '', 0, 1),
));
The closure receives the value after normalization (dates already formatted, enums reduced to their
backing value) and the source item, the same one actions receive. The closure is never serialized
to the browser. The API is fluent only: #[DataTableColumn] cannot hold a closure.
On a serverSide() table, ordering and searching keep working on the field. A client-side table
only holds the formatted value, so the browser orders and searches that text instead.
The returned value is still a cell value. A text column escapes it unless html() is set, in which
case the closure must escape any untrusted input itself. On a MoneyColumn, BooleanColumn or
ChoiceColumn, the formatted string replaces the typed rendering and is shown as escaped text.
Reach for TemplateColumn when a cell needs a Twig template (it is not exportable by default), and
for mapRow() only when the whole row needs reshaping, because it replaces the default mapper for
every column.
Summaries
summarize() shows footer figures over every row matching the search and filters:
use Pentiminax\UX\DataTables\Column\NumberColumn;
use Pentiminax\UX\DataTables\Model\Summary\Average;
use Pentiminax\UX\DataTables\Model\Summary\Sum;
NumberColumn::new('total')->summarize(Sum::new('Revenue'), Average::new());
It takes any number of Summarizer objects, one per SummaryType. Only Count accepts a
non-numeric column. It throws an InvalidArgumentException for a column that renders no data field
or for a numeric summary on a non-numeric column. See Column Summaries.
Export
$column->setExportable(false); // Exclude from CSV/Excel exports
Exportable columns automatically receive the dt-exportable CSS class, which the default
export selector (.dt-exportable:visible) relies on. DataTables’ column selector only
accepts a plain CSS selector before :visible, so the marker is positive rather than a
:not() exclusion.
Edit Form Control
$column->hideWhenUpdating(); // Exclude from inline edit modal
Custom Options
Pass arbitrary options to the frontend renderer:
$column->setCustomOption('myKey', 'myValue');
ColumnType Enum
The ColumnType enum controls DataTables.net’s internal sort and search algorithm for a column:
| Type | Description |
|---|---|
STRING | Plain text (default for TextColumn) |
STRING_UTF8 | UTF-8 aware text sorting |
NUM | Numeric values |
NUM_FMT | Formatted numbers ($1,000) |
DATE | Date values |
HTML | HTML content — sorts/filters on plain text extracted from markup |
HTML_NUM | Numeric values extracted from HTML |
HTML_NUM_FMT | Formatted numeric values extracted from HTML |
HTML_UTF8 | HTML with UTF-8 aware text sorting |
Translating Column Titles
Pass a Symfony translator key as the title and it will be resolved automatically when using AbstractDataTable:
yield TextColumn::new('name', 'datatable.columns.name');
yield TextColumn::new('email', 'datatable.columns.email');
Serialization
jsonSerialize() returns the DataTables.net configuration array for a column:
$config = $column->jsonSerialize();
// ['data' => 'name', 'title' => 'Full Name', 'orderable' => true, ...]