kitpages/data-grid-bundle
Kitpages DataGridBundle is a Symfony bundle for building configurable data grids with pagination, sorting, filtering, and customizable columns. It helps turn Doctrine queries or arrays into reusable, template-friendly grid views for admin and listing pages.
This is a Symfony bundle, not Laravel — everything below assumes a Symfony app (it was written for the Symfony 2.8–4.x era). If you're actually in Laravel, the equivalent is yajra/laravel-datatables or spatie/laravel-query-builder; read this for the shape of the abstraction (grid config + datasource + request state), not the API.
Install and register:
composer require kitpages/data-grid-bundle
// config/bundles.php
Kitpages\DataGridBundle\KitpagesDataGridBundle::class => ['all' => true],
There is almost nothing to configure. The only knob most projects touch is which template renders the table (the bundle ships a Bootstrap 3 template), e.g. in config/packages/kitpages_data_grid.yaml:
kitpages_data_grid:
template: '@KitpagesDataGrid/Grid/bootstrap3.html.twig'
Where to look first, in this order:
README.md — it contains the canonical 15-line Doctrine ORM example; copy it verbatim before reading anything else.Resources/views/Grid/*.html.twig — this is the documentation for what a column/toolbar/pager renders and which variables your custom cell templates receive.DependencyInjection/Configuration.php — the real list of config keys your installed version accepts (it differs between 1.x/2.x, so don't trust blog posts).Datasource/DoctrineOrmDatasource.php — read it once; it's ~200 lines and tells you exactly how sorting, filtering and counting are translated to DQL, which is where 90% of surprises come from.First use case: an admin CRUD list. You want an entity table with sortable headers, per-column text filters, and pagination, with zero hand-written table markup. That is precisely the bundle's sweet spot — it is not a full admin generator (no forms, no actions, no row editing).
Mental model, three objects:
GridConfig — you declare columns, labels, sortable/filterable flags, datasource, page size.Datasource — you hand it a Doctrine QueryBuilder (or an array, or your own implementation); it applies sort/filter/pagination.GridRequest — the bundle reads page, sort, order, filter[...] out of the query string, namespaced by grid name, so state is bookmarkable and shareable.Put the grid config in a service, not in the controller. This is the single pattern that keeps the bundle pleasant to live with; inline configs rot fast.
namespace App\Grid;
use Doctrine\ORM\EntityManagerInterface;
use Kitpages\DataGridBundle\Datasource\DoctrineOrmDatasource;
use Kitpages\DataGridBundle\Grid\GridConfig;
use Kitpages\DataGridBundle\Grid\GridConfigInterface;
class UserGridConfig implements GridConfigInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function getGridConfig(): GridConfig
{
$qb = $this->em->createQueryBuilder()
->select('u')
->from(\App\Entity\User::class, 'u')
->leftJoin('u.company', 'c')->addSelect('c'); // join, don't lazy-load per row
$grid = new GridConfig();
$grid->setName('users'); // namespaces the query string
$grid->setDatasource(new DoctrineOrmDatasource($qb));
$grid->addColumn('id', 'u.id', ['label' => 'ID', 'sortable' => true, 'filterable' => true]);
$grid->addColumn('email', 'u.email', ['label' => 'Email', 'sortable' => true, 'filterable' => true]);
$grid->addColumn('company', 'c.name', ['label' => 'Société','sortable' => true, 'filterable' => true]);
$grid->addColumn('createdAt', 'u.createdAt', ['label' => 'Créé le', 'sortable' => true, 'filterable' => false, 'format' => 'd/m/Y']);
$grid->setPaginator(true);
$grid->setMaxPerPage(25);
return $grid;
}
}
Controller stays trivial:
#[Route('/users', name: 'user_index')]
public function index(GridManager $gridManager, UserGridConfig $config): Response
{
return $this->render('user/index.html.twig', [
'grid' => $gridManager->getGrid($config->getGridConfig()),
]);
}
In 2.x the manager pulls the current request from the request stack; older versions expect an explicitly built GridRequest. If sort/page never change, that's the line to check.
Twig, all or in pieces. kitpages_data_grid(grid) renders filter row + table + pager. When you want the toolbar/buttons in your own layout, render the pieces separately (kitpages_data_grid_filter(grid), kitpages_data_grid_pager(grid) — confirm names with bin/console debug:twig | grep kitpages):
{{ kitpages_data_grid_filter(grid) }}
<div class="table-responsive">{{ kitpages_data_grid(grid) }}</div>
{{ kitpages_data_grid_pager(grid) }}
Custom cells: use a twig-typed column rather than fighting the default formatter. Map the column to a field you can sort by, then render whatever you want:
$grid->addColumn('actions', 'u.id', [
'label' => '',
'sortable' => false,
'filterable' => false,
'type' => 'twig',
'twig' => 'grid/column/actions.html.twig',
]);
{# templates/grid/column/actions.html.twig — mirror the variables used in the bundle's column template #}
<a href="{{ path('user_edit', {id: item.id}) }}" class="btn btn-sm btn-default">Éditer</a>
Non-Doctrine sources. ArrayDatasource for aggregated/report data and small API payloads (sorting/filtering then happens in PHP over the whole array — fine at 200 rows, a memory incident at 200k). For anything else implement DatasourceInterface: you provide getData($offset, $limit), getNbTotalRows(), and the filter setter/getter; the grid handles the rest. This is the cleanest way to put an Elasticsearch or REST endpoint behind the same UI.
Multiple grids on one page just need distinct setName() values — that's what prevents their page/sort/filter params from colliding.
Export is a second action, not a checkbox away: build the same QueryBuilder, skip the grid paginator, fputcsv to php://output. Twenty lines, and it reuses the filter values from the query string so "export what I'm looking at" works. (2.x also has an Export namespace/toolbar item; check it before writing your own.)
External search box: a normal GET form whose field name matches the bundle's namespaced filter param. Read the generated filter input's name attribute in the HTML — that's your contract, and it's stable across versions in a way the docs aren't.
QueryBuilder instance. DoctrineOrmDatasource mutates the QB (order by, and depending on version setMaxResults/setFirstResult). Build a fresh one inside getGridConfig() every call, or you'll get order-by leakage between grids and "row 26 shows up on page 1" bugs.u.email, c.name), not column aliases. Passing email where the query has no alias produces a DQL error deep inside the datasource, not a friendly validation message.LIKE %value% against the mapped field. On Postgres that blows up for integer/date columns (operator does not exist: integer ~~ unknown) — mark those filterable => false or map to a casted expression. On MySQL it silently defeats indexes; on a large table, per-column free-text filters are the first performance complaint you'll get.GROUP BY, DISTINCT, or HAVING in your QB the total can disagree with the rows, and pagination goes wrong at the last page. Test a grid with a join that duplicates rows before shipping it.leftJoin(...)->addSelect(...) for displayed relations.<ul class="pagination"> classes are dated. Override the template in templates/bundles/KitpagesDataGridBundle/Grid/… rather than editing vendor/; copying the whole template and adjusting the classes is a 10-minute job and the intended extension point.dump($grid->getGridRequest()) for parsed state, dump($qb->getDQL())/getParameters() (log it inside a local fork of the datasource if needed) for the actual query, bin/console debug:container kitpages_data_grid for the service ids, bin/console debug:twig | grep kitpages for the real function names in your version.visible => false keeps a column configured (useful for hidden sort/filter targets) while omitting it from markup; it's the cleanest way to sort by a column you don't want to display.composer.json, budget a day for Symfony-upgrade breakage, and be prepared to fork — it's MIT, the surface area is small, and the parts you'd have to maintain are one datasource class and a handful of Twig templates. That's acceptable for an internal admin screen; it is not acceptable as the foundation of a customer-facing feature with a multi-year life.GridConfig service, throw away the bundle, and render a plain Twig table from the same QueryBuilder plus a hand-rolled pager. The bundle's value is the first 200 lines of boilerplate, not the lock-in.How can I help you explore Laravel packages today?