Download the PHP package jul6art/datatable-bundle without Composer

On this page you can find all versions of the php package jul6art/datatable-bundle. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package datatable-bundle

logo dev in the hood

Symfony datatable bundle

License Version

A server-driven table over an API Platform collection: pagination, sorting, global search, column filters, per-row and bulk actions, confirmation modals, and live refresh over Mercure — declared in PHP, drawn by one Stimulus controller.

Extracted from an application that runs sixty of them.

Requirements

Suggested, and what each unlocks:

Package Without it
twig/twig the three Twig extensions and the two partials are not registered; only the PHP configuration providers are
symfony/security-csrf _csrf.html.twig and _preferences.html.twig call csrf_token(), which does not exist — the partial fails at render. The preferences endpoint then skips its token check, SameSite=Lax remaining the baseline
api-platform/core nothing to read: the filter conventions the helpers produce (?param[after]=) are API Platform's
jul6art/api-bundle no OrSearchFilter for the global search, no CaseInsensitiveOrderFilter for the sort

Front-end, in the application's own package.json or importmap: datatables.net-dt and datatables.net-responsive-dt, plus jquery and select2 for the autocomplete filters.

Installation

Configuration

datatable.enabled, datatable.stimulus_identifier, datatable.translation_domain, datatable.csrf.single, datatable.csrf.bulk and datatable.csrf.preferences are exposed as container parameters.

tenant.label_domain defaults to translation_domain rather than to messages, so moving the catalogue does not mean repeating the domain.

Usage

1. Declare the table in PHP

One subclass per listing. Use the helpers rather than literal arrays: every label goes through the translator, and a hand-written array is how a table ends up with one translated header next to a raw key — which no test catches, because a table configuration has no expected output.

⚠️ sortField is not optional on a computed column. Without it the front sends ?order[fullName]= and the API answers unsorted — in silence. A column that cannot be sorted says so with readOnlyColumn().

⚠️ dateRangeFilter's granularity is not cosmetic. 'date' (the default) sends the civil date as picked, for a date_immutable column. 'datetime' converts it to a UTC instant, for a datetime column. Getting it wrong shifts every result by one day for every user whose browser is not on UTC — an invoice dated the 1st stops matching a range starting the 1st.

2. Render the table

Both partials are required, and both go inside the <table> tag — they emit attributes, not elements. Without _csrf no POST action is authorised; without _translations the filter chrome renders raw keys.

searchable-fields-value is the list the global search hits through OrSearchFilter. It is not derived from the columns on purpose: a table often searches fields it does not display.

A page can hand the table a term to open on — a header search's "see all 47 results" link lands on the list filtered by the same term:

It wins over the term remembered in sessionStorage, on the first build only (a column drag does not bring it back over what was typed since), and the search box shows it. The table then opens on that term alone — no remembered filter, no starred view — so the count the link promised is the count on screen. Empty — the default — changes nothing.

3. Wire the front end

Register four Stimulus controllers under these identifiers — the markup the table renders names them:

File in this bundle's assets/controllers/ Identifier
datatable_controller.js whatever stimulus_identifier says
modal_controller.js ui--modal
tooltip_controller.js ui--tooltip
select2_controller.js ui--select2

And the stylesheets, which use Tailwind's @apply:

⚠️ Add the bundle's assets/ to Tailwind's content. A class used only in this bundle's JavaScript is otherwise purged from the production stylesheet — and only from the production one, which is the worst place to find out.

4. Register the badge renderers of your own domain

The controller ships twenty-one generic renderers: statusBadge, activeBadge, booleanBadge, iri, userIri, userNameWithAvatar, nameLink, date, dateOnly, monoCode, truncated, colorSwatch, country, currency, number0, number2, quantity, percent0, fileSize, durationMs, chipList.

⚠️ Dates render as DD/MM/YYYY HH:mm (dateOnly: DD/MM/YYYY), in every language. The format is deliberately NOT locale-driven: Intl's short style renders 8/25/26 in English and 25/08/26 in French — the same digits in the opposite order, with nothing on screen to say which one you are reading. A 03/04/26 is unreadable without knowing the locale that produced it. The time part is still converted to the reader's timezone; only the layout is fixed.

Everything with business vocabulary in it is yours:

An entry is (controller) => (data, type, row, meta) => string. The extra hop exists because a renderer needs the controller — c.t() for its labels, c.columnsValue to read its own column's configuration — and an arrow function has no this to bind.

The labels a badge() reads come straight from the catalogue: badge('datatable.quote_status', …) resolves datatable.quote_status.<case> through c.t(). The labelPath is therefore a catalogue key prefix, and it is the same string as the key_prefix of the matching datatable.status_maps entry — the two go in pairs, and {@see Translation\DeclaredTranslationKeys} is what tells the project's translation guard that those keys are alive.

5. Per-row and bulk endpoints

A postAction() submits a form carrying the single CSRF token; a bulk action posts ids[] with the bulk one. On the controller side:

jul6art/core-bundle ships BulkActionRunner for the aggregate side: token, ids[], one query to load them, the voter per row, one transaction.

⚠️ A bulk endpoint carries its own #[IsGranted]. The per-row voter loop is the second guard, not the first — an aggregate route has to fail fast with a 403 rather than iterate.

Cross-tenant listings

AdminDataTableConfig inserts the tenant column — second, right after id — and its autocomplete filter, so a super-admin page reuses the same provider a tenant user sees instead of a parallel copy that drifts one column at a time. Leave datatable.tenant.endpoint empty in a single-tenant application and never call it.

Per-user preferences

Each user arranges a table for themselves: which columns they see, in what order, and a handful of named views — a saved set of filters, one of which can open the table. Two dropdowns in the toolbar, one attribute in the template.

This bundle interprets the preferences. It does not store them: persistence is a port the application implements, because the shape it already has for per-user data is the shape it should keep. A bundle that shipped an entity would force a migration on every consumer and own a table none of them named.

1. Implement the store

Three guarantees an implementation owes:

Until an implementation is registered, the endpoint removes itself from the container: the feature is simply not there, rather than failing the build over a controller nobody asked for.

2. Import the route

Where it sits in the URL map is also what decides the firewall around it, so the bundle does not declare it:

One route serves every table: GET, PUT and DELETE on /{key}. That is what makes the feature opt-in in one line of Twig instead of a controller per entity.

3. Opt a table in

The key names a table, not an entity: two screens listing the same entity with different columns are two keys. Pattern: [a-z0-9][a-z0-9_.-]{0,63}.

Without the include, the table renders exactly as before — no request, no buttons. A table with three columns does not need a column picker.

Declaring wide, showing narrow

A column the reader can hide costs nothing to the reader who does not want it, so the arbitration changed: the question is no longer "does this column deserve the width?" but "could anyone want to see it?". Pass hidden: true for the second kind — the column is in the picker, absent from the first paint, one tick away:

Three things worth knowing:

resolveField works on mobile too

An iri column reads its label from the field named by extra: ['resolveField' => …], and until 1.4.1 that field was honoured on the desktop table but not in the mobile card list: the card looked up its column descriptor by object identity, and _columns is a getter over a Stimulus value — which re-parses its JSON attribute on every read, so no two reads are ever ===. Every lookup missed, and every renderer fell back to its default. iri defaults to resolveField: 'name', so tables whose relations expose name never noticed; a column resolving anything else (label, fullName, reference) showed a dash on a phone and the right value on a laptop.

What the user gets

Panel Actions
Columns tick to show or hide, drag to reorder, one button back to the declared layout. The last visible column cannot be hidden
Views apply a saved set of filters, sort and columns, name the current one, star one as the default, delete one

The two buttons sit in the global search's own layout cell, immediately before it, and drop onto their own line on a narrow viewport. The views button wears the name of the view currently applied — the only place that is visible with the panel closed.

Precedence, decided once and worth knowing:

  1. the starred view wins — filters, sort and columns. "Default view at opening" is an explicit, durable instruction; the session's sticky filters are an implicit convenience. The cost is stated rather than hidden: with a view starred, an ad-hoc filter does not survive a navigation. That is what starring one asks for, and un-starring it gives the sticky behaviour back;
  2. this session's state (sessionStorage) — what keeps a filter across "open a row, come back" when nothing is starred;
  3. the saved sort preference, then the template's default-order.

A view is a seed, not a lock: the next filter, sort or column change detaches it, and the panel stops showing it as active. It never carries a page size — that is a preference of its own.

The page number the session remembers belongs to the query that produced it, and is kept only if that query is the one about to run (since 2.4.1). Otherwise the table opens on page 1. Without that, leaving a screen on page 5 of an unfiltered list and coming back to a starred view with three rows opened page 5 of a three-row query: no rows on screen, a footer reading "101 to 3 of 3", and nothing to say why.

A view carries its columns (since 2.4.0)

A saved view stores the columns it shows, in the order it shows them — visible keys only, under columns. What it hides is not stored: the client rebuilds that half from the columns the table declares. The full {key, visible} shape would cost about four times the bytes for a layout nobody can see, and twenty views on a wide table would then push the blob past its 16 KB ceiling, where encode() drops saved views to fit — silently.

Reordering columns saves and then rebuilds the table in place (since 2.3.0): visibility has a DataTables API and changes without a redraw, order has none — ColReorder is a separate plugin, and adopting it would add a third numbering of the columns to a system built on column keys. So the table is destroyed and built again.

That used to reload the whole page. destroy() re-inserts the <table>, so Stimulus queues a disconnect() / connect() pair on the element — and it was that connect() running the boot a second time, building a second table on the same node, that made a reload look like the only way out. Both callbacks land in a microtask, after the rebuild has returned, so a data-datatable-rebuilding flag set for the length of the tick neutralises them. The flag is on the element, not on the controller instance: Stimulus may hand the reconnection to a new instance, which would otherwise re-subscribe to Mercure on top of the one still driving the table.

A rebuild is not a page load: the filters, the sort, the search and the page on screen are kept as they are. Replaying the opening precedence would let a starred view reclaim them on every column drag — the gesture would change what the table shows, not just the order of its columns.

What the server does and does not validate

It bounds everything: counts, lengths, one default view at most, view ids derived from names and deduplicated, 16 KB of JSON. It does not check that a column key or a filter parameter exists — one route serves every table, so at that point there is no way to know which columns erp_product has. The vocabulary is reconciled client-side, where the declared columns are in hand: an unknown key is dropped, a column added since the last save is appended. Both happen every time a *DataTableConfigProvider is edited, and neither may lose the rest of the layout.

The response is always the sanitised state, never an echo of the request — the client adopts it, so a name that was cut or a duplicate that was suffixed shows immediately instead of coming back changed on the next page load.

Exporting a view

With jul6art/dataflow-bundle installed, DatatableViewExporter turns a table's declared columns, a user's SAVED preferences, and the CURRENT request's filters into a real file — reusing that bundle's report engine rather than a second writing pipeline:

⚠️ There is no ONE export route the way there is one preferences route. The entity, the URL and the firewall around it are yours — exactly as they already are for the API Platform collection the table itself reads. This service is the one piece that would otherwise be duplicated across every such controller.

⚠️ A table opts in with ONE method. AbstractDataTableConfigProvider::rootEntity() returns null by default — every existing config provider, unmodified, stays not exportable. Override it with the entity the table already reads from:

⚠️ No saved preferences is not "export nothing". It is the state of every user before their first visit to the column picker, and the export matches what the table itself shows by default — every declared column, in declaration order, minus the ones marked hidden: true.

⚠️ Filters translate from three declared shapes, not from the query string freely. static and api narrow by identity (one value becomes eq, several become in); daterange reads API Platform's own ?param[after]= / ?param[before]= convention into gte / lte / between. A project's own filter type does not translate: this service would have to guess its shape, and a wrong guess is a silently wrong export rather than a missing one.

⚠️ A static/api filter on a RELATION sends an IRI, and the export follows it, not the declared column. stage.name is the right path to export stage as a column, but it is the wrong one to filter stage by identity against /api/deal_stages/4 — a plain DQL = can never match a name field to a URL. A value shaped like an IRI (/…/<id>) is read as that id, and the filter's path is rewritten to <relation>.id (stage.name → stage.id) — not the BARE relation: FieldCatalog only ever lists scalar fields, so a path with no leaf at all is refused as "not a reportable field" even though ReportRunner would gladly compare it as a foreign key. A value that is not shaped like an IRI — a plain static option (true, electronics) — keeps its own declared path and value exactly.

⚠️ An iri column exports the field it actually shows, not the bare relation. A column reading render: 'iri' displays a related entity through resolveField (default name, the same fallback datatable_controller.js applies) — the export builds the path <column>.<resolveField> for it, because the bare relation key is not something the report engine can select.

⚠️ A column with no reportable equivalent has to say so. A chip list built from a collection, a badge computed in the API resource — rootEntity() opts a TABLE in, but the report engine still needs a real Doctrine path for every exported column, and only the project knows which of its own renderings are not one:

Left out, such a column reaches ReportRunner as a phantom field and the WHOLE export fails with "is not a reportable field" — found wiring the very first real table into this feature, not predicted in advance. Marked reportable: false, it is dropped the same way a preference naming a column removed since the last save already is: the export degrades, it does not fail whole.

⚠️ DatatableViewExporter is removed by DatatableExportPass when no DatatablePreferenceStoreInterface is bound — the same reasoning PreferenceControllerPass applies to the preferences endpoint, and the same reason jul6art/dataflow-bundle is a suggest, never a require, of this bundle: an application that never installs it renders no export button at all.

The toolbar button

The service above is reachable; nothing yet asks for it. One more include, inside the <table> tag, next to the preferences one:

That renders a button in the same cluster as the column picker and the saved views — an icon and a label, datatable.export.button, which DeclaredTranslationKeys now requires. It downloads exactly what is on screen: the CURRENT filters (read from the same _activeFilters the live table already sends), refreshed on every draw so a saved view, a cleared filter or a Select2 change updates the button's target instead of only the next click's — a middle-click or "open in new tab" never fires a click handler this controller could otherwise hook.

⚠️ No CSRF value, unlike the preferences partial. The route reads in GET — the filters travel in the query string, the same as any other read — and a GET has no state to protect from another origin. Nothing to configure beyond the URL.

⚠️ Independent of _preferences.html.twig. A table can be exportable without saving column layouts, or the other way around; each partial keys off its own value, and either can be included without the other.

Live refresh

The controller subscribes to the Mercure feed through services/mercure-bus.js, a single shared EventSource (browsers cap them at about six per domain). A change touching a visible row reloads the page of data; a burst shows a "refresh" banner instead of reloading N times.

Two meta tags drive it, rendered by the application's layout:

The token endpoint returns { token, subscribed: [...] }, and subscribed[] is authoritative for both the JWT allow-list and the subscription list — so the topics are decided in one place instead of drifting between a template and a claim. jul6art/push-bundle mints the token (SubscriberCookieFactory) and publishes the changes (EntityChangePublisher).

JavaScript translations

Since v2 the controller reads its labels from the catalogue symfony/ux-translator dumps into the browser, through the registry of jul6art/core-bundle. There is no translation attribute on the table any more.

What a project has to do

  1. Install the socle, as described in the core-bundle README (symfony/ux-translator, the @symfony/ux-translator alias, registerTranslator() in assets/app.js).
  2. Add core-bundle to FRONT_BUNDLES in bundle-assets.js: the mixin now re-exports @jul6art/core-bundle/mixins/translatable, and without the alias the build fails to resolve.
  3. Move the datatable.* keys the browser reads into the javascript domain — and with them the modal.* keys, renamed datatable.modal.* (see below).
  4. Remove every {{ include('@Datatable/datatable/_translations.html.twig') }} from the templates.
  5. Point declaredKeys() / declaredPrefixes() of the project's AbstractJsTranslationTestCase at Translation\DeclaredTranslationKeys.

Breaking changes, and what each one was

Gone Why What replaces it
@Datatable/datatable/_translations.html.twig it posted 8.7 kB of escaped JSON into every page carrying a table, re-sent on every request and never cached the catalogue, dumped once into the JS bundle
datatable_status_map() it TRANSPORTED enum labels; the browser now has them status_maps stays, as a declaration read by DeclaredTranslationKeys
datatable_bulk_translations() same, for the bulk bar and the modals the keys are read directly
|merge_recursive it existed to graft one translation tree onto another —
status_maps.*.path it said where to nest the dictionary in that tree —
status_maps.*.domain one domain now, for the whole browser core.js_translations.domain
bulk_actions it existed to enumerate which modal.<type>.* keys to translate and ship the prefix datatable.modal. is declared instead
modal.<type>.* (key names) the controller has always read them as datatable.modal.<type>.*; the Twig extension re-prefixed them on the way out rename the catalogue keys to datatable.modal.*

⚠️ Two aria-labels were fixed on the way. The controller read bulk.select_all while the partial sent datatable.bulk.select_all, so every bulk-selection checkbox of every back office carried the literal string bulk.select_all as its aria-label. Nothing could see it: the guard checked each half in its own file. The keys are now datatable.bulk.select_all and datatable.bulk.select_row on both sides — which, since there is only one side left, is simply the key.

New keys to translate

getLanguageConfig() no longer carries a { fr, en } table of hard-coded sentences — eleven of them in French, with English left almost empty, which is why a five-locale product fell back to a half-filled English on three of its languages. They are now catalogue keys:

⚠️ _MENU_, _START_, _END_, _TOTAL_ and _MAX_ are DataTables' own placeholders, substituted long after the translator is done. They travel inside the translated string and must survive translation untouched.

Select2's messages (since 2.5.4)

The select2 controller used to pass language: document.documentElement.lang — a locale code, which Select2 only translates when the project has loaded select2/dist/js/i18n/<locale>.js. Without it, "Please enter 1 or more characters" and "No results found" showed in English on a French page. The controller now hands Select2 the messages themselves, from the catalogue, in every locale the project declares:

The table's own filters start from the same messages since 2.5.5 (their datatable.filter.* keys still win for the three they had), so "Loading more results…" and the clear button's label follow the page too.

A Select2 the project initialises itself takes the same messages:

A project that require()d Select2's locale files for the controller's sake can drop them.

⚠️ A key the catalogue does not translate is left out of the object, so Select2 keeps its own message for it rather than printing the raw key: upgrading before adding the keys changes nothing.

Labels the table draws on its own (since 2.5.6)

Five labels used to be written into the controller, two of them in French on pages of every language: the API filter of the mobile sheet said "— Rechercher —", its static filters "— Tous —". They are catalogue keys now:

A filter's own placeholder still wins over filter.all and filter.search.

⚠️ A key the catalogue does not translate falls back to English ("Actions", "Action edit", "All", "Search", "No data available in table") — the same rule as the Select2 messages: never the raw key, never French. HardCodedTextSourceTest fails on any string the bundle's JavaScript draws as written.

Quality assurance

Run composer qa, not the single tool you have in mind: the CI's "Coding standards" job runs Rector too, and its lowest deps job installs the minimum of every constraint — which is where this ecosystem has repeatedly found what a local run could not.

License

The Datatable bundle is open-sourced software licensed under the MIT license.

© 2026 jul6art


All versions of datatable-bundle with dependencies

PHP Build Version
Package Version
Requires php Version ^8.5
jul6art/core-bundle Version ^2.12 || ^3.0
symfony/config Version ^7.4 || ^8.0
symfony/dependency-injection Version ^7.4 || ^8.0
symfony/http-foundation Version ^7.4 || ^8.0
symfony/http-kernel Version ^7.4 || ^8.0
symfony/routing Version ^7.4 || ^8.0
symfony/security-core Version ^7.4 || ^8.0
symfony/translation-contracts Version ^3.4
symfony/yaml Version ^7.4 || ^8.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package jul6art/datatable-bundle contains the following files

Loading the files please wait ...