gesdinet/metronic-datatable-bundle
Gesdinet Metronic Datatable Bundle is a Symfony package for adding Metronic-styled DataTables to admin screens. It helps turn Doctrine queries into searchable, sortable, paginated AJAX grids with reusable Twig templates and less CRUD boilerplate. Ideal for back-office list and reporting pages.
The first thing to internalize: this is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no Eloquent integration. If you are evaluating this for a Laravel codebase, the only artifact you can realistically lift is the pattern (a per-grid server-side DataTables class backed by a query builder), not the code. For a Symfony shop it is a thin, Doctrine-coupled scaffolding layer for Metronic's jQuery DataTables integration.
Minimal path to a working grid:
composer require gesdinet/metronic-datatable-bundle — then immediately open its composer.json and check the require block. Small bundles in this category frequently pin symfony/* to ^4.4|^5.0; if you are on Symfony 6/7 you will be fighting the constraint resolver before you write a line of code.config/bundles.php (no Flex recipe is published for it):
Gesdinet\Bundle\MetronicDatatableBundle\MetronicDatatableBundle::class => ['all' => true],
doctrine/orm and twig are installed — the data source is a Doctrine QueryBuilder, and rows are rendered from Twig templates.DependencyInjection/Configuration.php and Resources/config/services.* before anything else. That is the whole API surface, and it tells you whether the bundle expects a service tag, a factory call, or an abstract base class to extend.First use case to build: an admin "Users" index page. That exercises everything the bundle exists for — pagination, full-text search, column sorting, a custom status badge renderer, and an AJAX endpoint behind the firewall. If that page works, the bundle is pulling its weight; if it doesn't, you have learned that in an afternoon rather than a sprint.
The mental model:
Twig: <table id="users"> with <thead> only
↓ (JS) jQuery DataTables, serverSide: true, ajax: /admin/users/datatable
Controller endpoint → your UserDatatable class → Doctrine QueryBuilder
↓ JSON: { draw, recordsTotal, recordsFiltered, data: [...] }
The bundle owns the left-to-right middle. It does not ship Metronic's CSS/JS — you supply the theme assets, and you write the DataTable({...}) init call.
One class per grid. Extend the bundle's base data table, set an id, set a query builder, declare columns, and implement a row transformer. The shape you should expect (verify names against the source, but the structure is stable across this family of bundles):
// src/DataTable/UserDataTable.php
class UserDataTable extends DataTable
{
public function configure(): void
{
$this->setId('users_admin')
->setQueryBuilder($this->em->createQueryBuilder()
->select('u', 'r')
->from(User::class, 'u')
->leftJoin('u.roles', 'r'))
->addColumn('id', 'u.id', ['orderable' => true])
->addColumn('email', 'u.email', ['orderable' => true, 'searchable' => true])
->addColumn('status', 'u.enabled') // formatted in getRow()
->addColumn('created', 'u.createdAt', ['orderable' => true]);
}
public function getRow($user): array
{
return [
'id' => $user->getId(),
'email' => $user->getEmail(),
'status' => $user->isEnabled() ? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-danger">Disabled</span>',
'created' => $user->getCreatedAt()->format('Y-m-d H:i'),
];
}
}
Keep getRow() pure. It is the single best unit-test target in the package — no DB, no request, just entity in / array out. Every formatting decision (dates, money, badges, link markup) belongs here, not in Twig and not in JS. This also keeps you locale-aware server-side instead of shipping raw ISO strings to the browser.
Whitelist orderable columns explicitly. DataTables sends order[0][column] as an integer index into the client-side column list. If your class blindly maps that index onto a DQL field name, you have an injection surface and a class of 500s when the client column order drifts from the server column order. Expose only the fields you declared with orderable => true, in the same order, and reject everything else.
Custom filters via ajax.data. The bundle handles DataTables' native search[value], but your business filters (date range, tenant, "only disabled users") are yours:
$('#users_admin').DataTable({
serverSide: true,
processing: true,
ajax: {
url: '{{ path("admin_users_datatable") }}',
data: d => { d.tenant = getCurrentTenant(); d.onlyDisabled = filterState.onlyDisabled; }
},
columns: [{ data: 'id' }, { data: 'email' }, { data: 'status' }, { data: 'created' }]
});
Then read those extra keys off the Request in configure() and apply them to the QueryBuilder with bound parameters. Build a small Symfony filter form and bind it to the same filterState object so the form and the grid never disagree.
Eager-load everything the row needs. getRow() executes once per row, so any lazy association touched inside it is an N+1 multiplied by page size. Add ->addSelect('r') (as above) or a fetch-join for every relation referenced in the transformer. This is the single highest-leverage performance decision in the whole implementation.
Cache the counts, not the rows. On large tables, COUNT(*) over the filtered query is often the most expensive statement in the request and it is identical for every page a user clicks through. Cache recordsTotal per table with a short TTL and invalidate on write; leave recordsFiltered uncached unless your filter set is small and enumerable.
Endpoint hygiene. Wrap the JSON endpoint in the same firewall/role as the page it serves — the grid is a data exfiltration path if you forget, and it's easy to forget because it's just a GET returning JSON. Also disable HTTP caching on it or you will serve one user's rows to another behind a shared proxy.
Wrong framework, wrong ORM. Symfony bundle, Doctrine QueryBuilder, Twig. None of it ports to Laravel without a rewrite: QueryBuilder → Eloquent/DB::query(), service config → service provider, the base class → an abstract you author. Budget for that up front rather than discovering it at integration time.
Adoption is effectively zero. Three stars, zero dependents, last release August 2024. Treat it as code you now own. Pin an exact version in composer.json, commit the lock file, and assume you will fork it the first time a Symfony upgrade breaks it. The MIT license makes that clean, which is the one genuinely favorable thing about the situation.
Metronic version skew is the most likely integration failure. Metronic has shipped multiple generations of its own DataTables wrappers (older Datatable/KTDatatable JS classes versus plain jQuery DataTables init), and they do not all expect the same JSON envelope. The bundle emits the classic { draw, recordsTotal, recordsFiltered, data } shape. If you drop it behind a newer Metronic wrapper expecting a different contract, you get an empty grid with a 200 response and no console error — the worst possible failure mode. Compare the bundle's output against your theme's expected response format before wiring the front end.
recordsTotal vs recordsFiltered is the classic bug. recordsTotal must be the count before filters; recordsFiltered is the count after. Swapping them produces a grid that paginates incorrectly but looks fine on page one — a bug that survives QA and surfaces in production on page two.
Joins break pagination and counts. A LEFT JOIN that fans out rows makes setMaxResults()/setFirstResult() return fewer distinct entities than requested, and makes counts wrong. Use ->distinct(), or run a separate scalar COUNT(DISTINCT ...) query, or paginate on IDs and hydrate afterwards.
Hydration mismatch. Mixing getArrayResult() with a getRow() that expects entities (or vice versa) yields "call to a member function on null" far from the actual cause. Pick one mode and enforce it in the base class.
Search strings reach DQL. Bind everything as a parameter. Never interpolate $request->query->get('search')['value'] into a DQL string — it is user input hitting a query language.
Debugging workflow. The Symfony profiler's Doctrine panel is the tool: a single grid page should show roughly two queries (count + page), not page-size + two. If you see the latter, it's the N+1 above. For the JSON contract, hit the endpoint directly with curl and inspect the raw payload before blaming the front end.
Extension points. There is no grid lifecycle event system — no preQuery, no postRow. If you need cross-cutting behavior (audit logging per grid, automatic tenant scoping, global soft-delete filter), put it in your own abstract base class between the bundle's class and your concrete grids, or decorate the factory service in services.yaml. Don't try to hook what isn't there.
Route registration is worth checking early. Depending on the version, routes may live in Resources/config/routing.xml (import it in config/routes.yaml) or be absent entirely, in which case you write your own controller and the bundle is purely a domain layer. Confirm which before estimating the work.
How can I help you explore Laravel packages today?