[FR] Bundle Symfony pour la génération dynamique de tableaux HTML avec tri, filtres et pagination. [EN] Symfony bundle for dynamic HTML table generation with sorting, filtering and pagination.
- Colonnes auto-détectées — les colonnes sont générées automatiquement à partir des clés de vos données
- Génération dynamique de tableaux via un composant Twig réutilisable
- Tri des colonnes — côté serveur (rechargement de page) ou côté client (JavaScript)
- Filtres configurables — texte libre, liste déroulante, plage de dates
- Filtres séparés — template indépendant, plaçable n'importe où dans la page
- Pagination intégrée — avec navigation et ellipsis
- CSS par défaut — nommage BEM, responsive mobile (les lignes se transforment en cartes)
- JavaScript vanilla — zéro dépendance, auto-initialisation
- API fluide — style Builder inspiré du FormBuilder de Symfony
Ajoutez le repository et le package dans votre composer.json :
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/Jaecko/owl-table-bundle"
}
],
"require": {
"owl-concept/table-bundle": "dev-main"
}
}Puis lancez :
composer updateEnregistrez le bundle dans config/bundles.php :
return [
// ...
OwlConcept\TableBundle\OwlTableBundle::class => ['all' => true],
];Installez les assets (CSS & JS) :
php bin/console assets:installIncluez le CSS et le JS dans votre template de base :
<link rel="stylesheet" href="{{ asset('bundles/owltable/css/owl-table.css') }}">
<script src="{{ asset('bundles/owltable/js/owl-table.js') }}" defer></script>Configuration optionnelle dans config/packages/owl_table.yaml :
owl_table:
default_mode: server # 'server' ou 'client' (défaut: server)
default_per_page: 20 # Éléments par page (défaut: 20)
css_class_prefix: owl-table # Préfixe CSS (défaut: owl-table)Passez simplement vos données, le tableau se construit tout seul :
use OwlConcept\TableBundle\Builder\TableBuilder;
#[Route('/users', name: 'users_list')]
public function list(TableBuilder $tableBuilder): Response
{
$users = [
['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'Admin'],
['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'User'],
];
$table = $tableBuilder->create('users_table')
->setData($users) // Les colonnes "name", "email", "role" sont auto-détectées
->build();
return $this->render('user/list.html.twig', ['table' => $table]);
}Les en-têtes sont générés automatiquement :
created_at→ Created at,firstName→ First name,
Deux syntaxes possibles :
// Mode associatif — par clé
$table = $tableBuilder->create('users_table')
->setData($users)
->setLabels(['name' => 'Nom', 'email' => 'Courriel', 'role' => 'Rôle', 'created_at' => 'Créé le'])
->build();
// Mode indexé — dans l'ordre des colonnes détectées
$table = $tableBuilder->create('users_table')
->setData($users)
->setLabels(['Nom', 'Courriel', 'Rôle', 'Créé le'])
->build();En mode indexé, les labels sont appliqués dans l'ordre des clés détectées. Vous pouvez ne renommer que les premières colonnes :
->setLabels(['Nom', 'Courriel'])ne renomme que les 2 premières.
Chaque aspect est configurable via sa propre méthode, sans toucher à configureColumn() :
$table = $tableBuilder->create('users_table')
->setData($users)
->setLabels(['Nom', 'Email', 'Rôle', 'Créé le'])
->setSortable(['name', 'email', 'created_at'])
->setFilterable(['name' => 'text', 'role' => 'select', 'created_at' => 'date_range'])
->setFilterOptions(['role' => ['Admin', 'User', 'Editor']])
->setFormatters(['created_at' => fn($v) => date('d/m/Y', strtotime($v))])
->setMode('server')
->handleRequest($request)
->build();| Méthode | Mode indexé | Mode associatif |
|---|---|---|
setLabels() |
['Nom', 'Email'] (dans l'ordre) |
['name' => 'Nom'] (par clé) |
setSortable() |
['name', 'email'] (ces clés deviennent triables) |
— |
setFilterable() |
['name', 'role'] (type text par défaut) |
['name' => 'text', 'role' => 'select'] |
setFilterOptions() |
— | ['role' => ['Admin', 'User']] |
setFormatters() |
— | ['price' => fn($v) => ...] |
setCssClasses() |
['bold', '', 'right'] (dans l'ordre) |
['name' => 'bold'] |
setHeaderClass() |
— | Classe globale sur tous les <th> : 'bg-dark text-white' |
setHeaderClasses() |
['w-50', 'w-25', '', 'w-25'] (dans l'ordre) |
['name' => 'w-50'] |
configureColumn() reste disponible pour tout regrouper sur une colonne :
->configureColumn('role', [
'label' => 'Rôle',
'filterable' => true,
'filter_type' => 'select',
'filter_options' => ['Admin', 'User'],
'css_class' => 'text-center',
])Priorité :
configureColumn()>setLabels()/setSortable()/ etc. > auto-détection
Si certaines lignes ont des clés que d'autres n'ont pas, le tableau détecte l'union de toutes les clés :
$data = [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => 'bob@example.com', 'phone' => '06 12 34 56 78'],
];
// Résultat : 3 colonnes → Name, Email, Phone
// Alice aura une cellule vide pour "Phone"
$table = $tableBuilder->create('contacts')->setData($data)->build();{% extends 'base.html.twig' %}
{% block body %}
<h1>Utilisateurs</h1>
{# Les filtres peuvent être placés n'importe où : sidebar, en-tête, modal... #}
<div class="sidebar">
{% include '@OwlTable/filters.html.twig' with { table: table } %}
</div>
{# Le tableau avec pagination incluse automatiquement #}
{% include '@OwlTable/table.html.twig' with { table: table } %}
{% endblock %}Le builder supporte directement les objets (via les getters et propriétés publiques) :
$users = $userRepository->findAll();
// Les colonnes sont détectées via getName(), getEmail(), getRole(), etc.
$table = $tableBuilder->create('users_table')
->setData($users)
->setSortable(['name', 'email'])
->build();->setFormatters([
'price' => fn($v) => number_format($v, 2, ',', ' ') . ' €',
'active' => fn($v) => $v ? 'Oui' : 'Non',
'created_at' => fn($v) => $v instanceof \DateTimeInterface ? $v->format('d/m/Y') : $v,
])| Type | Description | Paramètres |
|---|---|---|
text |
Champ texte libre avec recherche partielle (insensible à la casse) | — |
select |
Liste déroulante | filter_options : tableau des valeurs possibles |
date_range |
Deux champs date (du / au) | — |
| Mode serveur | Mode client | |
|---|---|---|
| Tri | Liens <a> avec paramètres URL, rechargement de page |
Boutons <button>, tri instantané en JS |
| Filtres | Formulaire <form method="get">, soumission classique |
Écoute des événements input/change, filtrage instantané |
| Pagination | Liens <a> avec ?page=N |
Boutons <button>, changement de page sans rechargement |
| Données | Seule la page courante est dans le HTML | Toutes les données sont embarquées en JSON dans un attribut data-* |
| Cas d'usage | Grands jeux de données, requêtes DB paginées | Petits jeux de données (< 500 lignes) |
En mode serveur avec une base de données, utilisez les accesseurs du builder pour construire vos requêtes :
$table = $tableBuilder->create('users_table')
->setData([]) // données vides pour l'instant
->setSortable(['name', 'email'])
->setFilterable(['name' => 'text'])
->setMode('server')
->handleRequest($request);
// Récupérer les valeurs parsées pour construire la requête DB
$sortField = $tableBuilder->getParsedSortField(); // ex: 'name'
$sortDir = $tableBuilder->getParsedSortDirection(); // ex: 'asc'
$filters = $tableBuilder->getParsedFilters(); // ex: ['name' => 'alice']
$page = $tableBuilder->getParsedPage(); // ex: 2
$perPage = $tableBuilder->getParsedPerPage(); // ex: 20
// Requête Doctrine avec ces paramètres
$qb = $repo->createQueryBuilder('u');
// ... appliquer les filtres et le tri ...
$total = count($qb->getQuery()->getResult());
$users = $qb->setFirstResult(($page - 1) * $perPage)->setMaxResults($perPage)->getQuery()->getResult();
$table = $tableBuilder
->setData($users)
->setPagination(page: $page, perPage: $perPage, total: $total)
->build();- Auto-detected columns — columns are automatically generated from your data keys
- Dynamic table generation via a reusable Twig component
- Column sorting — server-side (page reload) or client-side (JavaScript)
- Configurable filters — free text, dropdown select, date range
- Separate filter template — independent include, placeable anywhere on the page
- Built-in pagination — with navigation and ellipsis
- Default CSS — BEM naming, responsive mobile (rows become stacked cards)
- Vanilla JavaScript — zero dependencies, auto-initialization
- Fluent API — Builder pattern inspired by Symfony's FormBuilder
Add the repository and package to your composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/Jaecko/owl-table-bundle"
}
],
"require": {
"owl-concept/table-bundle": "dev-main"
}
}Then run:
composer updateRegister the bundle in config/bundles.php:
return [
// ...
OwlConcept\TableBundle\OwlTableBundle::class => ['all' => true],
];Install the assets (CSS & JS):
php bin/console assets:installInclude the CSS and JS in your base template:
<link rel="stylesheet" href="{{ asset('bundles/owltable/css/owl-table.css') }}">
<script src="{{ asset('bundles/owltable/js/owl-table.js') }}" defer></script>Optional configuration in config/packages/owl_table.yaml:
owl_table:
default_mode: server # 'server' or 'client' (default: server)
default_per_page: 20 # Items per page (default: 20)
css_class_prefix: owl-table # CSS prefix (default: owl-table)Just pass your data, the table builds itself:
use OwlConcept\TableBundle\Builder\TableBuilder;
#[Route('/users', name: 'users_list')]
public function list(TableBuilder $tableBuilder): Response
{
$users = [
['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'Admin'],
['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'User'],
];
$table = $tableBuilder->create('users_table')
->setData($users) // Columns "name", "email", "role" are auto-detected
->build();
return $this->render('user/list.html.twig', ['table' => $table]);
}Headers are generated automatically:
created_at→ Created at,firstName→ First name,
Two syntaxes available:
// Associative mode — by key
$table = $tableBuilder->create('users_table')
->setData($users)
->setLabels(['name' => 'Full Name', 'email' => 'Email Address', 'created_at' => 'Joined'])
->build();
// Indexed mode — in order of detected columns
$table = $tableBuilder->create('users_table')
->setData($users)
->setLabels(['Full Name', 'Email Address', 'Role', 'Joined'])
->build();In indexed mode, labels are applied in the order of detected keys. You can rename only the first columns:
->setLabels(['Full Name', 'Email Address'])only renames the first 2.
Each aspect has its own method, no need for configureColumn():
$table = $tableBuilder->create('users_table')
->setData($users)
->setLabels(['Full Name', 'Email', 'Role', 'Joined'])
->setSortable(['name', 'email', 'created_at'])
->setFilterable(['name' => 'text', 'role' => 'select', 'created_at' => 'date_range'])
->setFilterOptions(['role' => ['Admin', 'User', 'Editor']])
->setFormatters(['created_at' => fn($v) => date('M d, Y', strtotime($v))])
->setMode('server')
->handleRequest($request)
->build();| Method | Indexed mode | Associative mode |
|---|---|---|
setLabels() |
['Name', 'Email'] (in order) |
['name' => 'Name'] (by key) |
setSortable() |
['name', 'email'] (these keys become sortable) |
— |
setFilterable() |
['name', 'role'] (default text type) |
['name' => 'text', 'role' => 'select'] |
setFilterOptions() |
— | ['role' => ['Admin', 'User']] |
setFormatters() |
— | ['price' => fn($v) => ...] |
setCssClasses() |
['bold', '', 'right'] (in order) |
['name' => 'bold'] |
setHeaderClass() |
— | Global class on all <th>: 'bg-dark text-white' |
setHeaderClasses() |
['w-50', 'w-25', '', 'w-25'] (in order) |
['name' => 'w-50'] |
configureColumn() is still available to group all options on a single column:
->configureColumn('role', [
'label' => 'Role',
'filterable' => true,
'filter_type' => 'select',
'filter_options' => ['Admin', 'User'],
'css_class' => 'text-center',
])Priority:
configureColumn()>setLabels()/setSortable()/ etc. > auto-detection
If some rows have keys that others don't, the table detects the union of all keys:
$data = [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => 'bob@example.com', 'phone' => '+33 6 12 34 56 78'],
];
// Result: 3 columns → Name, Email, Phone
// Alice will have an empty cell for "Phone"
$table = $tableBuilder->create('contacts')->setData($data)->build();{% extends 'base.html.twig' %}
{% block body %}
<h1>Users</h1>
{# Filters can be placed anywhere: sidebar, header, modal... #}
<div class="sidebar">
{% include '@OwlTable/filters.html.twig' with { table: table } %}
</div>
{# Table with pagination automatically included #}
{% include '@OwlTable/table.html.twig' with { table: table } %}
{% endblock %}The builder supports objects directly (via getters and public properties):
$users = $userRepository->findAll();
// Columns are detected via getName(), getEmail(), getRole(), etc.
$table = $tableBuilder->create('users_table')
->setData($users)
->setSortable(['name', 'email'])
->build();->setFormatters([
'price' => fn($v) => '$' . number_format($v, 2),
'active' => fn($v) => $v ? 'Yes' : 'No',
'created_at' => fn($v) => $v instanceof \DateTimeInterface ? $v->format('M d, Y') : $v,
])| Type | Description | Parameters |
|---|---|---|
text |
Free text field with partial search (case-insensitive) | — |
select |
Dropdown select | filter_options: array of possible values |
date_range |
Two date fields (from / to) | — |
| Server mode | Client mode | |
|---|---|---|
| Sorting | <a> links with URL parameters, page reload |
<button> elements, instant JS sorting |
| Filters | <form method="get">, classic submission |
Listens to input/change events, instant filtering |
| Pagination | <a> links with ?page=N |
<button> elements, page change without reload |
| Data | Only the current page is in the HTML | Full dataset embedded as JSON in a data-* attribute |
| Use case | Large datasets, paginated DB queries | Small datasets (< 500 rows) |
In server mode with a database, use the builder's accessors to build your queries:
$table = $tableBuilder->create('users_table')
->setData([]) // empty data for now
->setSortable(['name', 'email'])
->setFilterable(['name' => 'text'])
->setMode('server')
->handleRequest($request);
// Get parsed values to build your DB query
$sortField = $tableBuilder->getParsedSortField(); // e.g. 'name'
$sortDir = $tableBuilder->getParsedSortDirection(); // e.g. 'asc'
$filters = $tableBuilder->getParsedFilters(); // e.g. ['name' => 'alice']
$page = $tableBuilder->getParsedPage(); // e.g. 2
$perPage = $tableBuilder->getParsedPerPage(); // e.g. 20
// Doctrine query using these parameters
$qb = $repo->createQueryBuilder('u');
// ... apply filters and sorting ...
$total = count($qb->getQuery()->getResult());
$users = $qb->setFirstResult(($page - 1) * $perPage)->setMaxResults($perPage)->getQuery()->getResult();
$table = $tableBuilder
->setData($users)
->setPagination(page: $page, perPage: $perPage, total: $total)
->build();Proprietary — Owl Concept