kreyu/data-table-bundle
Kreyu DataTableBundle is a Symfony bundle for building feature-rich data tables with sorting, filtering, pagination, bulk actions, AJAX loading, and exports. Configure columns and actions declaratively, customize templates, and integrate quickly with a flexible, developer-friendly API.
kreyu/data-table-bundle is a Symfony bundle (not Laravel). Its design will feel familiar if you've used Symfony Forms: you declare a table as a class, the bundle renders it, and the request drives filters/sorting/pagination. As a Laravel dev, treat it as the equivalent of a "resource table" abstraction you'd otherwise hand-roll with Blade + query scopes — except the declarative layer is the product.
Minimal path:
composer require kreyu/data-table-bundle
# Flex enables it; otherwise register Kreyu\Bundle\DataTableBundle\KreyuDataTableBundle
Version note: the package is still on 0.x. v0.33.0 is the first release allowing Symfony 8 (on top of the existing 6/7 range), and it also fixes a Twig deprecation in DataTableExtension — so if you're on Symfony 8 / PHP 8.4, that's your floor. On Symfony 6/7 there's no urgency to move.
First place to look: the DataTableType docs and the examples in docs/ — not the source. Then copy the "basic usage" into one real controller.
// src/DataTable/ProductDataTableType.php
final class ProductDataTableType extends AbstractDataTableType
{
public function buildDataTable(DataTableBuilderInterface $builder, array $options): void
{
$builder
->addColumn('name', TextColumnType::class, ['sortable' => true])
->addColumn('price', MoneyColumnType::class, ['currency' => 'USD'])
->addColumn('createdAt', DateTimeColumnType::class, ['format' => 'Y-m-d'])
->addFilter('name', TextFilterType::class)
->addFilter('createdAt', DateFilterType::class);
}
}
#[Route('/products', name: 'app_product_index')]
public function index(Request $request, DataTableFactoryInterface $factory): Response
{
$qb = $productRepository->createQueryBuilder('p');
$dataTable = $factory->create(ProductDataTableType::class, $qb);
$dataTable->handleRequest($request);
return $this->render('product/index.html.twig', [
'dataTable' => $dataTable->createView(),
]);
}
{{ data_table(dataTable) }}
First real use case: replace an existing "index" action that concatenates Request params into a QueryBuilder, has a hand-written paginator, and a sortable <th> macro. That's exactly the code this bundle deletes.
One type per table, composed via parents. Keep ProductDataTableType thin and factor shared behaviour into an AbstractEntityDataTableType with getParent(). Options go through configureOptions(OptionsResolver $resolver) — same mental model as FormType, including setDefaults, setAllowedTypes, setRequired.
Pass the data source, let the bundle pick the adapter. A QueryBuilder gets the Doctrine ORM adapter; arrays/Traversable get the array adapter. That means the same DataTableType can serve a Doctrine-backed admin screen and an in-memory stub in tests. For anything non-trivial (joins, GROUP BY, DQL functions), construct the QueryBuilder in the controller or a dedicated factory and keep it out of the type class.
Reusable column/filter sets via builder composition. A common pattern is a TimestampableDataTableTrait-style helper that does $builder->addColumn('createdAt', ...) in a loop, so audit columns are consistent across every table.
Custom cells = Twig column type. Don't fight TextColumnType with getter closures for rich markup; use the Twig column with a small template and pass the row entity. Keep templates in one templates/data_table/ directory and register them as a theme so per-column overrides stay in one place.
Actions live on the builder, not in the template. Row and batch actions are declared with route name/params (lazy params can be closures receiving the row entity), with options for confirmation and method/CSRF. This is the biggest win over hand-rolled tables: the batch-action form, CSRF token and confirmation dialog are wired for you. Keep the actual handling in a separate controller action that reads the selected identifiers.
Filters/sorting/pagination all flow through handleRequest(). Call it exactly once, before createView(). For AJAX or Turbo-driven partial refreshes, re-render the same template and let the view's state (page, sorting, active filters) drive the markup — the shipped themes are already structured so the table region can be swapped.
Personalization for per-user state. Enable persistence so a user's filters, sorting and page survive navigation. Default storage is session-backed; swap in a custom PersonalizationStorageInterface if you want DB- or preference-backed state per user. Disable it for stateless/API endpoints.
Testing. Write a kernel/bundle test that builds the table, feeds a Request with query params, and asserts on createView() plus the adapter's item count. That catches filter regressions faster than browser tests, and it's the closest analogue to Laravel feature tests you'll get here.
handleRequest() is stateful and mutating. Calling it twice, or after createView(), produces confusing results. Do it in the controller, once, immediately after create().QueryBuilder you give it. Don't pass a shared/cached builder from a service — pass a fresh one (or a closure/factory). Otherwise filters from request A leak into request B in long-running workers.getTotalItemCount() breaks with to-many joins. Classic Doctrine paginator problem: add ->distinct() or restrict the select, otherwise page counts inflate and rows duplicate across pages.ORDER BY.bin/console cache:clear before concluding the override "isn't working".bin/console debug:container --tag=data_table.type (and the filter/column/action tags) to confirm autoconfiguration; debug:config kreyu_data_table for bundle config; dump $dataTable->getSorting() / filter data to see what the request actually produced. Most "the filter isn't applied" reports are a missing handleRequest() or a mismatched parameter name.^0.33 after testing, and check the upgrade notes before a composer update on a large app — especially if you have themed template overrides that reach into the shipped blocks.DataTableExtension is spamming deprecations in your logs, that's the upgrade that silences it — useful even if you're staying on Symfony 7.How can I help you explore Laravel packages today?