krystal-sf/ux-datatables
krystal-sf/ux-datatables is a Laravel/PHP package for building interactive, user-friendly data tables. It streamlines sorting, searching, pagination, and server-side data handling so you can add responsive, feature-rich tables to your application with minimal setup.
First, the framework reality check. This is a Symfony UX package — Twig + Stimulus + a Symfony bundle registration path. There is no supported way to composer require it into a Laravel app and have it work: Laravel has no Bundle/DependencyInjection\Extension/Flex machinery, and the AssetMapper/Encore asset story collides with Vite. If you are on Laravel, the only thing you can "leverage day-to-day" is the pattern (PHP-side table definition + server-side DataTables JSON contract). Everything below assumes you are either on a Symfony property or deliberately porting that server-side layer.
Where to look first (in this order):
composer.json — the require block tells you whether it hard-depends on symfony/ux-turbo, symfony/stimulus-bundle, symfony/form, and what PHP/Symfony versions it pins. This single file decides most of your questions.src/ — look for the base table class and the JSON/controller layer. Identify whether the entry point is a Form type, a builder object, or a controller attribute. That determines how much glue you own.assets/ folder — where the Stimulus controller lives and how it's registered (controllers.json vs manual bootstrap.js import vs a prebuilt file).docs/ — expect it to be written for Krystal's internal projects, not external consumers. Assume gaps.Minimal setup on a Symfony app:
composer require krystal-sf/ux-datatables
# if no Flex recipe: register the bundle manually
php bin/console cache:clear && php bin/console assets:install
// config/bundles.php (only if no recipe)
Krystal\Ux\DataTables\UxDataTablesBundle::class => ['all' => true],
Make sure the Stimulus controller is imported in assets/bootstrap.js (or listed in assets/controllers.json), then rebuild assets (npm run dev / php bin/console asset-map:compile).
First use case — pick one boring admin list. Not an audit log with composite filters, not a multi-tenant report. One Doctrine query, five or six columns, one global search, one date filter. That is the smallest surface that exercises the whole path (PHP table class → JSON endpoint → Stimulus → DataTables) and tells you whether the package's defaults match your app.
The four things you must get working before anything else:
draw / recordsTotal / recordsFiltered / data.data- attributes and URL.One table class per resource, never inline in the controller. The whole value proposition is that column definitions live in PHP, are testable, and are reused. Keep the pattern consistent:
class OrdersTable extends AbstractDataTable
{
public function columns(): array
{
return [
Column::make('id', '#')->orderable(),
Column::make('customer.name', 'Customer')->searchable(),
Column::make('total', 'Total')->format(fn ($row) => number_format($row->total, 2)),
Column::make('createdAt', 'Created')->format(fn ($row) => $row->createdAt->format('Y-m-d H:i')),
Column::actions('actions'), // rendered HTML, allowlisted
];
}
public function query(): QueryBuilder { /* base, already scoped */ }
}
Split "page" and "data" endpoints. This is the pattern that makes the package pleasant day-to-day:
GET /admin/orders — renders the Twig template with the table's configuration only (column headers, mount point, JSON URL). No data.GET /admin/orders.json — returns the DataTables JSON envelope; this is the only place that touches the query.Keeping them separate means the page is cacheable, the JSON endpoint is independently testable, and you have exactly one place to add authorization checks.
Configure the frontend once, per-table via data attributes. Don't fork the Stimulus controller per screen. Mount one controller and pass options as data- attributes; that keeps upgrades (or your internal mirror) tractable:
<div {{ stimulus_controller('ux-datatables', {
url: path('admin_orders_data'),
options: table.frontendOptions
}) }}></div>
Treat the query as the security boundary, not the JS. Multi-tenant scoping, soft-delete filters, and permission checks belong inside query(), before any DataTables parameter is applied. The JS is user-controllable; the base query is not.
Filters: prefer a small allowlist over a generic grammar. For each filterable concept, map it explicitly:
if ($search = $request->query->get('q')) {
$qb->andWhere('o.reference LIKE :q')->setParameter('q', "%{$search}%");
}
DataTables' columns[i][search][value] and order[i][column] are indexed by position — translate indices to your column objects, then to your allowlisted DQL field names. Never pass a client-supplied string into orderBy().
Project to DTOs, not entities. Server-side grids are read-heavy and entity hydration is where they get slow. select() the exact fields your columns render:
$qb->select('o.id', 'o.reference', 'o.total', 'o.createdAt', 'c.name AS customerName');
Formatting lives in the closure, not the Twig template. Every column that renders something non-trivial (badge, link, action buttons) gets a format() closure that returns a string. This is what keeps the client-side JS generic and the server-side testable.
Test the two halves separately. Unit-test the table class (column list, applied filters, generated SQL for a given parameter set). Functional-test the JSON endpoint (envelope keys, row shape, that ordering by column 3 actually sorts by the expected field). Don't try to test DataTables' rendering — that's the client library's job.
Wrap it before you depend on it. Even on Symfony, put an internal DataTableQuery contract in front of the vendor base class. Application code should never reference a krystal-sf namespace directly. That's what makes removal a sprint instead of a rewrite of every grid — and given the zero-star, zero-dependent status, removal is a when, not an if.
If you're on Laravel: don't port this. Take the server-side parameter parser as a reference implementation, write it against Illuminate\Database\Query\Builder in an afternoon, and use yajra/laravel-datatables or Filament tables as the baseline you measure against.
The bundle-registration trap. With no Flex recipe, the package doesn't register itself. Symptom: the Twig component/function is undefined, or the Stimulus controller is never emitted. Fix: config/bundles.php entry, then cache:clear. If it ships services with autowiring tags that expect a specific directory layout, read the bundle's Extension class — the config tree is the authoritative list of options, not the README.
Asset pipeline collision is the most common integration failure. AssetMapper and Encore/Vite both want to own assets/. If the package ships a prebuilt file, you can often just include it; if it ships a source path, you either adopt its pipeline or copy the controller into yours. Copying means you now own it — acknowledge that in the spike notes. Symptom when it's wrong: the table renders empty with no JS error, because the controller never connected.
The four-number contract. DataTables will silently accept a malformed response and just show "no records." Always return:
{ "draw": 1, "recordsTotal": 1200, "recordsFiltered": 43, "data": [ ... ] }
draw must echo the request's draw verbatim (or as an int). recordsTotal is the unfiltered count, recordsFiltered is the post-filter count. Getting those two swapped is the classic "pagination shows 1200 records but you can only page through 43" bug.
length=-1. DataTables can request the full dataset (length: -1) on export or "All" page size. That's an unbounded query. Clamp it server-side: $length = min((int) $request->get('length', 25), 200);. This is the single easiest DoS in any server-side DataTables implementation.
Two COUNT queries per request. recordsTotal and recordsFiltered each run a COUNT(*). On a filtered query with joins that's often slower than fetching the page itself. Mitigations: skip the unfiltered count by caching it per table+scope, and make sure your count query doesn't carry ORDER BY. GROUP BY/HAVING will break naive count wrapping entirely — test with your real query shape, not SELECT * FROM small_table.
N+1 via formatters. A format() closure that touches $row->customer->name lazy-loads per row. Either select() the joined field directly (preferred) or ->leftJoin()->addSelect() + ->addGroupBy(). Watch the Symfony profiler's query count on a 25-row page; if it's not ~3, something is lazy-loading.
XSS in formatted columns. format() returns a string that DataTables inserts as HTML (that's how action buttons and badges work). Anything derived from user data must be escaped by hand — htmlspecialchars() on the way out, and never interpolate a raw search term into a rendered cell. This is the trap that bites every PHP-first table library.
Column index vs column name. order[i][column] is a positional index into columns[], and columns[i][name] may be absent or arbitrary depending on the frontend config. Always resolve position → your column object → your allowlisted field. Never trust columns[i][data] as a field name.
Global search defaults to LIKE %term% on every searchable column, which is both slow and produces surprising results across numeric/date columns. Restrict the searchable set explicitly and consider matching the formatted value where users expect it (e.g. searching "2024-03" on a date column).
Stimulus controller name drift. The registered controller name (from controllers.json or the manual import) must match what the package's Twig helper emits. When you rebuild assets and the table goes blank, check the browser console for Error: Controller "..." is not registered before touching PHP.
Debugging checklist, in order: (1) Network tab → inspect the AJAX request payload and the JSON response; (2) is draw echoed and are both counts sane?; (3) is the count high but data empty → the query is wrong, not the JS; (4) is data populated but rows blank in the UI → column name mapping (data key doesn't match your DTO field); (5) is the SQL unexpected → dump the query builder's SQL and parameters before execution, and log the resolved DataTables params alongside it. Do not debug this from the browser alone.
Extension points worth knowing (and worth wrapping): per-column format() closures, a configurable frontend options array passed from PHP to the Stimulus controller, and — if present — a post-processing hook (event subscriber or method override) where you can add derived columns without rewriting the query. If the package lacks a post-processing hook, adding derived fields means a SELECT alias; do that rather than overriding the JSON action.
Version and ownership hygiene. Pin to a commit SHA, not a version range — a routine composer update should not be an event that changes your table rendering. Mirror the repo internally. Document the internal wrapper contract so the next person knows what to change when the upstream goes quiet, because with no dependents and a vendor-specific scope note, "quiet" is the expected steady state, not a risk scenario.
How can I help you explore Laravel packages today?