tejadong/datatables-bundle
A Laravel/PHP bundle for integrating DataTables with server-side processing. Build sortable, searchable, paginated tables from Eloquent or query builder data via a simple API, with flexible configuration for columns, filters, and responses.
Reality check before you start: despite the generic name, this is a Symfony bundle (Sg\DatatablesBundle lineage, forked by tejadong), not a Laravel package. There is no service provider, no facade, no Blade directive, no config/datatables.php to publish. If you're on Laravel, do not install this — use yajra/laravel-datatables. The only reason to reach for this fork is if you maintain a Symfony app (2.x–5.x era, depending on the fork's composer.json) and need DataTables.js server-side processing over Doctrine entities. You can, however, read it as a reference implementation for how server-side DataTables should behave: it models the request/response contract (draw, start, length, order[], search, columns[]) very cleanly.
Minimal path on Symfony:
composer require tejadong/datatables-bundle. With 0 stars and 0 dependents, pin an exact version in composer.json and commit the lockfile. Check the fork's composer.json constraints against your Symfony and Doctrine versions before anything else — this is where most forks die. If any column path traverses a Doctrine collection (see below), you need >=2.0.4; earlier 2.0.x releases silently return one row per collection member.config/bundles.php / AppKernel::registerBundles(). There is no Flex recipe; you add it by hand.src/Datatables/PostDatatable.php extending AbstractDatatable, implement buildDatatable(), getEntity(), getName():public function buildDatatable(array $options = [])
{
$this->ajax->set(['url' => $this->router->generate('post_datatable_results')]);
$this->features->set(['server_side' => true, 'processing' => true, 'state_save' => true]);
$this->options->set(['individual_filtering' => false, 'order_cells_top' => true]);
$this->columnBuilder
->add('id', Column::class, ['title' => 'ID'])
->add('title', Column::class, ['title' => 'Title'])
->add('author.name', Column::class, ['title' => 'Author'])
->add('createdAt', DateTimeColumn::class, ['title' => 'Created']);
}
router, request_stack, security.authorization_checker, translator (constructor signature varies by fork version — copy it from AbstractDatatable).$datatable->getResponse() after setQuery($qb).{{ datatable_render(datatable) }} (or _html + _js split) — the bundle emits both the <table> markup and the $(...).DataTable({...}) init script.First use case to build: a read-only paginated listing over one entity, no joins, no actions. Verify recordsTotal/recordsFiltered come back sane before adding anything else. This package rewards incremental adoption; every feature you bolt on before the base query paginates correctly will make debugging ambiguous.
Second use case, once the first is green: one many-to-one association column (author.name). Only after that works should you attempt a path that crosses a collection (pertenencias.centroDeportivo.nombre) — that's the shape that 2.0.4 fixes, and it exercises a different code path (GROUP_CONCAT + collection joins) than a plain association.
QueryBuilder is assembled in the controller (or a repository method) and injected via setQuery(). This keeps the DataTable class reusable across different filters/tenants.getEntity() returning 'AppBundle:Post' is the zero-effort path and only works for the simplest case. The moment you need a WHERE on a relation, a soft-delete condition, or a GROUP BY, switch to setQuery() with an explicit createQueryBuilder('post') — and make sure the root alias matches the alias used in column names.author.name). The bundle resolves these to joins automatically in the entity path; with a custom QB you usually need to add the leftJoin yourself and keep the alias matching (post.author → author.name).pertenencias.centroDeportivo.nombre crosses a OneToMany/ManyToMany and therefore cannot be a plain join — the bundle marks those joins as collection joins and aggregates the scalar with GROUP_CONCAT in setSelect()/GROUP BY. As of 2.0.4, every join from the first collection onward in the path is flagged (the $collectionJoins array) and none of their identifiers leak into SELECT/GROUP BY. Before that, only the final join was treated as the collection, so intermediate collection identifiers polluted the grouping. Practical rules:
GROUP_CONCAT-bundled string per row for that column; it's display-only. Don't filter or sort on it.Column, BooleanColumn, DateTimeColumn, TextColumn, LinkColumn, ActionColumn. DateTimeColumn handles client-side formatting; ActionColumn with a Route object is how you get edit/delete buttons without hand-writing JS.Column::class with 'render' => 'function(data, type, row, meta) { ... }' rather than dumping HTML from the server. Keep the server payload data-only. This applies doubly to collection columns — GROUP_CONCAT output is a delimited string, so split it in the render callback, not in Twig.individual_filtering => true) puts a search input in the header. It multiplies query count and is a common source of "search box appears but does nothing" tickets — pair it with setSearchColumns() on each column that should participate.ActionColumn render payload or a data- attribute read by your JS handler.BaseDatatable that pre-sets processing, server_side, state_save, pageLength, responsive/autoWidth, and the translator locale, then subclasses only declare columns. This is the single biggest maintainability win with this package.post_datatable_results), and check authorization there with a voter. The results endpoint is a data-leak surface: it will happily serialize whatever columns you declared, regardless of who's asking.AbstractDatatable::getResponse() (request parsing → draw/recordsTotal/recordsFiltered/data), the column-to-DQL mapping, and the search/order application. The collection handling in setSelect() is the piece most home-grown implementations get wrong — replicate the "flag all joins from the first collection onward" rule, not just "flag the last join". It's roughly 4–6 classes to reimplement on top of a Laravel query builder — a reasonable weekend port if you're rebuilding on Symfony-adjacent tooling.buildDatatable() gets called twice (once to render, once in the results action). Never accumulate mutable state inside it; anything set in buildDatatable must be idempotent.stwe/DatatablesBundle) is effectively abandoned, and forks like this one chase PHP 8 / Symfony 5+ compatibility. Before debugging any bug, confirm the fork's composer.json actually supports your Symfony minor — otherwise you'll chase ArgumentCountErrors in the AbstractDatatable constructor.src/ and own them. That removes the abandoned-dependency risk entirely and costs you an afternoon. The 2.0.4 fix is a good example of why: the bug sat in collection handling, it's a ~15-line change in setSelect(), and you'd rather have it in your own repo than wait on a maintainer.pertenencias.centroDeportivo.nombre, only the last join was treated as a collection, so the intermediate join's identifier stayed in the SELECT and GROUP BY. Symptoms, in the order you'll notice them:
iTotalDisplayRecords / recordsFiltered is higher than the real row count (one per collection element, not per root entity) — so the pager says "1 of 812" for a 90-row table.GROUP_CONCAT column shows a single value instead of the comma-joined list, because each group now has one member.SELECT COUNT(...) on the QB with the same joins returns the inflated number, it's this bug — upgrade to 2.0.4 rather than patching the query.->groupBy('post.id') manually, or added a DISTINCT, or used a scalar subquery instead of a collection path), remove the workaround after upgrading, because the bundle now emits its own grouping and you'll double up. Same for any having() hack on COUNT.WHERE clauses against pertenencias/centroDeportivo aliases keep behaving identically. Good place to add a regression test after upgrading.recordsTotal vs recordsFiltered is your smoke test. If recordsTotal is wrong, your count query is broken (usually raw SQL columns or a GROUP BY); if only recordsFiltered is wrong, your search/where clause is broken. Check both before touching the JS. Collection bugs almost always show up as recordsFiltered > recordsTotal or both inflated.addColumn-style computed columns break the count query and produce pagination that shows "1 of 500" but only pages through 12 rows. If a column is computed, mark it non-searchable/non-orderable explicitly.LIKE %term% on large tables is a full scan. For anything past ~100k rows, switch searchable columns to a MATCH ... AGAINST or a searchable filter in the controller, and consider disabling state_save (it triggers extra requests on every reload and confuses users debugging pagination).getResponse() returns a JsonResponse already shaped for DataTables. Wrapping or post-processing data breaks draw matching and silently freezes the table.data query string payload (start/length/order/search), then replay it with curl against the results route with your session cookie. If the JSON is correct and the table is empty, it's a columns[].data name mismatch — the most common bug in this whole ecosystem. If the JSON is wrong, log the fully-built DQL ($qb->getDQL() plus bound params) and compare the GROUP BY list against your column paths — that's how you'd have spotted the 2.0.4 bug in minutes.orderable. Ordering by a hidden or computed column often yields "no sorting" with no error. Ordering by a collection-aggregated column is not supported at all — mark it orderable => false.$this->ajax, $this->features, $this->options, $this->callbacks, $this->events) — they're thin wrappers that emit raw DataTables config. When the bundle lacks a feature, set the raw JS option through $this->callbacks/$this->events rather than patching the class.How can I help you explore Laravel packages today?