crossknowledge/datatable-bundle
A Symfony bundle for building datatables with server-side data handling, reusable configuration, and straightforward integration into PHP admin interfaces. It helps teams add sortable, filterable tables to back-office screens without duplicating table logic.
Before you composer require anything, understand what you're adopting: crossknowledge/datatable-bundle is a Symfony bundle, not a Laravel package. There is no ServiceProvider, no facade, no config publish tag, and its data source layer is built on Doctrine ORM QueryBuilder — not Eloquent. The DataTableFactory you see in the docs is resolved out of Symfony's DI container. In a pure Laravel app you cannot pull it in and call it a day; you'd be bootstrapping a Symfony kernel and a Doctrine EntityManager inside Laravel, which is almost never worth it.
So treat this package as one of three things, in order of realistic value:
draw, start, length, order, search → paginated JSON). Highest value for a Laravel dev.Where to look first (regardless of path):
DataTableFactory — how a table is constructed and how handleRequest() parses the payload.DataSourceInterface (fetchData() / fetchCount()) — the abstraction worth copying.Column\Formatter\FormatterInterface — per-column value formatting.draw, recordsTotal, recordsFiltered, data.Recommended first use case: pick your heaviest admin index (users, orders, audit logs) and reimplement the bundle's protocol handling on Eloquent. One table, end to end, is enough to validate the pattern.
If you do run it for real, the install is:
composer require crossknowledge/datatable-bundle
# requires symfony/framework-bundle, doctrine/orm, twig/twig ^3.0
// config/bundles.php — Symfony app only
CrossKnowledge\DataTableBundle\CrossKnowledgeDataTableBundle::class => ['all' => true],
Frontend is unchanged either way:
$('#example').DataTable({
processing: true,
serverSide: true,
ajax: '/your-endpoint'
});
DataTables always sends the same shape, so the work is deterministic: parse, whitelist, query, count, map, respond.
// app/DataTables/ServerSideTable.php
abstract class ServerSideTable
{
abstract protected function query(): Builder;
abstract protected function columns(): array; // ['name' => ['label' => ..., 'sortable' => true, 'searchable' => true, 'format' => fn($v) => ...]]
public function handle(Request $request): JsonResponse
{
$draw = (int) $request->input('draw', 1);
$start = (int) $request->input('start', 0);
$length = (int) $request->input('length', 25);
$base = $this->query();
$total = (clone $base)->count();
// global search across whitelisted searchable columns
$term = $request->input('search.value');
if ($term) {
$base->where(function ($q) use ($term) {
foreach ($this->columns() as $col => $meta) {
if ($meta['searchable'] ?? false) {
$q->orWhere($col, 'like', "%{$term}%");
}
}
});
}
$filtered = (clone $base)->count();
// order — never trust the client's column name blindly
$orderIdx = (int) $request->input('order.0.column', 0);
$orderDir = $request->input('order.0.dir') === 'desc' ? 'desc' : 'asc';
$orderCol = array_keys($this->columns())[$orderIdx] ?? 'id';
$base->orderBy($orderCol, $orderDir);
$rows = $base->skip($start)->take($length)->get();
return response()->json([
'draw' => $draw,
'recordsTotal' => $total,
'recordsFiltered' => $filtered,
'data' => $rows->map(fn ($r) => $this->mapRow($r))->all(),
]);
}
}
DataSourceInterface abstractionThe bundle's best idea is separating "where rows come from" from "how they're presented" (fetchData() / fetchCount()). Port it so Elasticsearch, an API, or an array source plugs in the same way:
interface DataSourceInterface
{
public function fetchData(array $options): array; // start, length, order, search
public function fetchCount(array $options): int;
}
class EloquentDataSource implements DataSourceInterface { /* ... */ }
class ElasticDataSource implements DataSourceInterface { /* ... */ }
This is also your escape hatch if you later move the table into a Symfony service — the shape is already compatible.
Mirror addColumn() / setFormatter() with a fluent column builder so formatters live next to their column:
$this->columns = [
'id' => Column::make('ID'),
'name' => Column::make('Name')->searchable()->sortable(),
'created_at' => Column::make('Created')
->format(fn ($v) => $v->format('Y-m-d')),
];
Run it in a Symfony service, keep the endpoint pure JSON (no Twig rendering), and bridge auth (shared session/JWT). This is also insurance: the 4.1.0 "upgrade twig" release becomes a no-op for you because you never render through Twig.
$dataTable = $factory->create();
$dataTable->setDataSource($queryBuilder); // Doctrine QueryBuilder
$dataTable->addColumn('id', 'ID');
return $dataTable->handleRequest($request);
Call it from Laravel with Http::withToken(...)->get('/reporting/users', $clientParams) and pass the DataTables params straight through.
Don't mock the whole HTTP stack. Build a fake payload and assert the JSON shape (draw echoed back, recordsTotal ≥ recordsFiltered, correct page size):
$response = $this->getJson('/admin/users?draw=2&start=10&length=10');
$response->assertJson(['draw' => 2])->assertJsonCount(10, 'data');
php artisan vendor:publish, no Eloquent adapter. setDataSource() expects Doctrine QueryBuilder. Any "just install it" plan in a Laravel codebase is a port project in disguise. The native Laravel equivalent is yajra/laravel-datatables — reach for that if Laravel is the only stack; use this bundle only when Symfony/Doctrine is already in play.recordsFiltered must be computed with exactly the same WHERE clause as the data query. Joins + DISTINCT/GROUP BY break naive count() — in Doctrine this is a classic pitfall, and it translates directly to Eloquent: use distinct()->count('id') or a subquery count, and test with a filtered query, not just the unfiltered one.$row->customer->name, you've just fired length extra queries. Eager-load what formatters need, or select the joined column directly.draw must be echoed back verbatim (as an integer). If you return a string or drop it, DataTables silently discards the response and you get a spinner forever — a genuinely confusing symptom.Twig_Extension → Twig\Extension\AbstractExtension, Twig_SimpleFunction → Twig\TwigFunction. Pin twig/twig: ^3.0 in root composer.json; check blockers with composer why-not twig/twig ^3.0.dump($request->query->all()) in Symfony; dd($request->all()) in Laravel). Nine out of ten "it returns nothing" bugs are a mismatched param name (search[value] vs search) or a missing columns[i][data] mapping.debug: true. Laravel: DB::listen(fn ($q) => logger($q->sql)) — you'll immediately see whether the count query picked up the search WHERE.default_length, default_order, and locale are set in cross_knowledge_data_table.yaml. Remember default_order there is only a fallback — the client's order param wins, so your "sensible default" can be silently overridden by saved DataTables state.DataSourceInterface (custom/non-Doctrine sources), the FormatterInterface (reusable currency/date formatters instead of inline closures), and the data_table.build.query event (mutate the query without touching the table class — in Laravel, a QueryBuilder macro or a TableBuilding event does the same job).How can I help you explore Laravel packages today?