Data Providers & Row Mappers
Contracts and built-in implementations for data retrieval and row mapping
With AbstractDataTable, providers are resolved internally and row mapping is applied through a
pipeline. The usual extension points are:
createDataProvider()for custom providersmapRow()for domain-to-array mappingcreateRowMapper()when you need the built-in mapping/template/action pipeline instance
DataProviderInterface
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
use Pentiminax\UX\DataTables\Model\DataTableResult;
interface DataProviderInterface
{
public function fetchData(DataTableRequest $request): DataTableResult;
}
Refusing data source features
A provider that cannot apply SearchBuilder criteria, ColumnControl searches, configured filter
values, or column summaries declares it through FeatureAwareDataProviderInterface. The bundle then
refuses those requests for every path that reads the provider (data, export, and bulk selection)
instead of letting the provider answer unfiltered: criteria get HTTP 400, and a table that declares
summaries throws a LogicException.
use Pentiminax\UX\DataTables\Contracts\DataProviderInterface;
use Pentiminax\UX\DataTables\Contracts\FeatureAwareDataProviderInterface;
use Pentiminax\UX\DataTables\DataTableRequest\DataTableRequest;
use Pentiminax\UX\DataTables\Enum\DataSourceFeature;
use Pentiminax\UX\DataTables\Model\DataTableResult;
final class WarehouseStockProvider implements DataProviderInterface, FeatureAwareDataProviderInterface
{
public function supports(DataSourceFeature $feature): bool
{
return DataSourceFeature::ConfiguredFilters === $feature;
}
public function fetchData(DataTableRequest $request): DataTableResult
{
// ...
}
}
A provider that does not implement the interface is assumed to support every feature.
Built-in providers:
| Provider | Use case |
|---|---|
DoctrineDataProvider | Doctrine ORM-backed datasets |
ArrayDataProvider | In-memory or preloaded arrays |
ArrayDataProvider
ArrayDataProvider serves a page from an in-memory collection. Give it the table’s resolved
columns so it can honor the request:
use Pentiminax\UX\DataTables\Contracts\DataProviderInterface;
use Pentiminax\UX\DataTables\DataProvider\ArrayDataProvider;
protected function createDataProvider(): ?DataProviderInterface
{
return new ArrayDataProvider(
$this->rows,
$this->createRowMapper(),
$this->getResolvedColumns(),
);
}
getResolvedColumns() returns the configured columns after static permission filtering — the same
list the Doctrine provider queries with. The argument is optional: without columns the provider
still pages correctly but nothing is orderable, and a request carrying a search term raises rather
than returning every row as a match. The fourth $filters argument is deprecated since 1.3: the
bundle rejects a value for any of the table’s configured filters on its own.
Supported request features
| Request feature | Behavior |
|---|---|
| Pagination | Honored. Only the returned page reaches the row mapper. |
| Ordering | Honored, by the column's source value. getOrderExpression() is DQL and is ignored here. Strings compare with strnatcasecmp(), other values with <=>, and rows without a value sort last in both directions. |
| Global search | Honored on globally searchable columns, as the Doctrine provider does. The term is trimmed and matching is case-insensitive. |
| Per-column search | Honored, cumulatively: a row must match every active column search. |
| SearchBuilder criteria | Not supported — the request gets HTTP 400. |
| ColumnControl searches | Not supported — the request gets HTTP 400. |
Configured Filters | Not supported — a value for a configured filter gets HTTP 400. |
| Column summaries | Honored, in memory. |
Search and ordering read the source value of each item — the property named by the column’s
field path — not the mapped row. A field path traverses any mix of nested arrays and objects.
Backed enums use their string or integer value for both operations. This matches the Doctrine
semantics: a column with no matching property, such as an ActionColumn or a TemplateColumn, is
simply not searchable or orderable in memory. Non-text values (booleans, dates) are not searchable,
exactly as they are excluded from a Doctrine LIKE.
A table that needs SearchBuilder, ColumnControl, or configured Filters in memory must implement
DataProviderInterface itself; the refusal exists so those requests fail loudly instead of
returning unfiltered rows with HTTP 200.
RowMapperInterface
interface RowMapperInterface
{
public function map(mixed $row): array;
}
Built-in mappers and processors:
| Mapper | Use case |
|---|---|
DefaultRowMapper | Default row-to-array mapping behavior |
RowProcessingPipeline | The pipeline AbstractDataTable builds: stages, then URL, template, and action resolution |
RowStageInterface
Each stage in RowProcessingPipeline implements RowStageInterface:
interface RowStageInterface
{
public function process(array $mappedRow, mixed $originalRow, array $columns): array;
}
Built-in stages applied by default (in order):
| Stage | Responsibility |
|---|---|
NormalizationStage | Dotted-path resolution, DateColumn formatting, Stringable casting |
IconColumnResolutionStage | Resolves each IconColumn state into row icon metadata (__ux_datatables_icons) |
BooleanSwitchMetadataStage | Records the row id behind every switch-rendered BooleanColumn (__ux_datatables_boolean_switches) |
After those stages, RowProcessingPipeline still resolves URL columns, renders TemplateColumn
cells via Twig, and resolves ActionColumn URLs into __ux_datatables_actions. That work runs
inside the pipeline rather than as separate RowStageInterface classes.
The stage list is assembled by the runtime factory and is final on the table:
AbstractDataTable::createRowMapper() cannot be overridden, and no service tag adds a stage. To
change how a row is mapped, override mapRow() — it is the base mapping every stage runs on top
of:
protected function mapRow(mixed $row): array
{
$mappedRow = parent::mapRow($row);
if (isset($mappedRow['title'])) {
$mappedRow['title'] = strtoupper($mappedRow['title']);
}
return $mappedRow;
}
To act after the pipeline instead — once template columns and action URLs are already resolved —
wrap the built mapper in a RowMapperInterface of your own and hand that to a provider built in
createDataProvider(). An anonymous class delegating to createRowMapper() is enough; it is also
the replacement for the closure-backed mapper the bundle used to ship:
use Pentiminax\UX\DataTables\Contracts\DataProviderInterface;
use Pentiminax\UX\DataTables\Contracts\RowMapperInterface;
use Pentiminax\UX\DataTables\DataProvider\ArrayDataProvider;
protected function createDataProvider(): ?DataProviderInterface
{
$inner = $this->createRowMapper();
$mapper = new class ($inner) implements RowMapperInterface {
public function __construct(private readonly RowMapperInterface $inner)
{
}
public function map(mixed $row): array
{
$mappedRow = $this->inner->map($row);
$mappedRow['title'] = strtoupper($mappedRow['title'] ?? '');
return $mappedRow;
}
};
return new ArrayDataProvider($this->rows, $mapper, $this->getResolvedColumns());
}
DataTableResult
new DataTableResult(
recordsTotal: 1000,
recordsFiltered: 150,
data: $rows,
summaries: ['total' => ['sum' => 1250.5, 'count' => 150]],
);
summaries is optional. It holds the footer figures over every matching row, keyed by column name
then summary type (array<string, array<string, int|float|string|null>>), or null when no column
declares a summary. Providers that cannot compute them must refuse the DataSourceFeature::Summaries
feature. See Column Summaries.
When To Implement Custom Types
- custom domain filters with non-Doctrine backends
- APIs requiring specific output shape
- performance tuning with pre-mapped row payloads
Manual Provider Example
use Pentiminax\UX\DataTables\Contracts\DataProviderInterface;
use Pentiminax\UX\DataTables\DataProvider\ArrayDataProvider;
protected function createDataProvider(): ?DataProviderInterface
{
return new ArrayDataProvider($this->rows, $this->createRowMapper(), $this->getResolvedColumns());
}
$this->createRowMapper() is the important part: it preserves the same mapping, template
rendering, and action-resolution behavior as the built-in Doctrine provider. setData() on
AbstractDataTable uses that same pipeline for inline rows.
Page Projection
projectPage() transforms a complete, already-paginated page of source entities, so a page can be
batch-enriched without an N+1. When the projected rows are a DTO carrying its own
#[DataTableColumn] declarations, name it as the table’s
dataClass and keep the queried
entity as entityClass. A server-side export has no page: it streams every filtered row, so
the projector is called once per batch instead, with a batch size unrelated to the DataTables page
length. Project each item from itself rather than from the batch it arrived in. See
Custom Exporters.