kilik/table
A Laravel package for building dynamic, customizable data tables with fluent column definitions, sorting, filtering, search, pagination, row actions, and Blade rendering. Generate responsive tables from Eloquent models or arrays with minimal code.
Install
composer require kilik/table
Register the bundle — config/bundles.php on Symfony 4+:
Kilik\TableBundle\KilikTableBundle::class => ['all' => true],
Then publish its public assets (the bundle ships jQuery-based JS/CSS):
php bin/console assets:install --symlink public/
First place to look — Kilik\TableBundle\Components\Table, Column, Filter and FilterType. These are the only classes you'll touch on a normal day; everything else (the TableFactory, the response service, the Twig extension) exists to serialize a Table object into JSON and back.
Wire up a table as a service. Don't new Table() in a controller — the bundle discovers tables by service tag:
app.table.user:
class: App\Table\UserTable
tags:
- { name: kilik_table.table, alias: user }
// src/Table/UserTable.php
class UserTable extends Table
{
public function __construct(EntityManagerInterface $em)
{
$qb = $em->createQueryBuilder()
->select('u')
->from(User::class, 'u');
$this->setId('user')
->setPath('user_table_data') // route that returns JSON
->setTitle('Users')
->setQueryBuilder($qb)
->setRowsPerPage(25)
->addColumn(
(new Column())->setName('u.id')->setLabel('ID')
->setSortable(true)
->setFilter((new Filter())->setType(FilterType::TYPE_NUMBER))
)
->addColumn(
(new Column())->setName('u.email')->setLabel('Email')
->setSortable(true)
->setFilter((new Filter())->setType(FilterType::TYPE_TEXT))
);
}
}
Expose the Ajax endpoint — one trivial controller action per table:
/**
* @Route("/user/table-data", name="user_table_data")
*/
public function userTableAction(Request $request, TableFactory $factory)
{
$table = $factory->getTable('user');
return $this->get('kilik_table.service')->getResponse($table, $request);
}
Render it in Twig and let the bundle's JS do the Ajax:
{{ kilik_table_render('user') }}
Add the bundle's assets to your layout (or your Encore/Webpack config) and the table becomes sortable, filterable and paginated with zero extra code.
First real use case: the "list screen" of any CRUD — a paginated, sortable, per-column-filterable user/order/product list backed by a Doctrine query, where only the filter/sort/page params round-trip to the server.
Table-as-service is the unit of composition. One Table subclass per grid, one tagged service, one route, one Twig call. Keep the query builder construction in the constructor so the definition is stable; if you need request-aware query tweaks (e.g. tenant scoping), do it in the controller before calling getResponse(), not inside the table.
Column naming follows DQL aliases. setName('u.email') must match an alias/field that exists in the select() of your query builder. If you select('u'), use u.email; if you select('u.id, u.email'), use those scalar names. Sorting and filtered WHERE/HAVING clauses are generated from these names, so a mismatch surfaces as a Doctrine Semantical Error.
Filters per type. FilterType::TYPE_TEXT, TYPE_NUMBER, TYPE_SELECT, TYPE_DATE behave differently on the server (LIKE vs =, date ranges vs single value). For TYPE_SELECT supply the choices eagerly:
(new Filter())
->setType(FilterType::TYPE_SELECT)
->setOptions(['active' => 'Active', 'locked' => 'Locked'])
or a callback that hits a repository when the option set is large.
Renderers for presentation-only transforms. Column::setRenderer(fn($row) => ...) receives the raw row (aliased keys like $row['u.email']) and must return the HTML/string to display. Use this for badges, formatted dates, links, and status pills instead of mutating the entity in the query. Keep renderers pure — they're called once per row and cannot see the request.
Joins and relations. Add joins explicitly in the query builder (->leftJoin('u.group', 'g')->addSelect('g')) and then expose g.name as a column. Avoid selecting whole relation graphs for large tables; select scalars for the columns you display.
Multiple tables on one page. Each table gets its own id, path, and Twig render call; the JS keys off the id, so there is no collision as long as the ids are unique. This is the normal pattern for dashboards.
Integration into existing pages. The endpoint is just a controller returning JSON — you can put it behind your normal firewall, reuse route prefixes, and wrap it with a @Security attribute. Because it's GET, the browser caches are the main concern (see gotchas).
Export/bulk actions. The bundle is display-only. If you need CSV export, build a sibling route that re-uses the same Table object's query builder and filters rather than duplicating query logic — pass the current filter params through.
Column::setName() must resolve in the DQL. If you use addSelect('COUNT(x) AS cnt'), expose it as cnt, not u.cnt.ORDER BY COUNT(x) on a non-grouped query throws. Either setSortable(false) on computed columns or add the aggregate to GROUP BY/HAVING yourself.<span class="badge">...</span> and it shows as literal text, wrap it (new HtmlString(...) / Twig |raw) or configure the bundle's template — don't html_entity_decode in the renderer.assets:install) or wired into Encore — a 404 on the bundle JS manifests as a table that renders but never filters.Cache-Control: no-store on the response, or add a cache-busting param on the JS side.select only displayed fields) before reaching for custom count logic.TYPE_NUMBER filter with an empty input should be skipped, not coerced to 0. Verify the generated DQL via the profiler's query list when filters "silently" return wrong rows.?page=1&sort=u.email&order=asc (the exact parameter names the bundle's JS sends — inspect the network tab once and note them; do not rename the route or its params). The response is the fastest way to tell whether the problem is server-side query generation or client-side JS.Filter subclass, custom column renderers for anything visual, and Twig template overrides under templates/bundles/KilikTableBundle/ for markup changes (.twig override paths win automatically).How can I help you explore Laravel packages today?