ghaliano / easyadmin-grouping-bundle
Grouped list view for EasyAdmin: extends the native index template with nested header rows between groups, or renders a collapsible tree. Multi-level grouping via property paths, callables, or custom strategies.
Package info
github.com/ghaliano/easyadmin-grouping-bundle
Type:symfony-bundle
pkg:composer/ghaliano/easyadmin-grouping-bundle
Requires
- php: >=8.2
- easycorp/easyadmin-bundle: ^4.25
- symfony/framework-bundle: ^7.0
- symfony/property-access: ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-05-18 17:15:54 UTC
README
A grouping layer for EasyAdmin index pages. Adds a "grouped view" mode that groups rows by one or more criteria. Two display variants:
- Table view (default): extends EA's native index template. Rows stay in the same table; header rows are inserted between groups. All row-level actions, column filters, EA's pagination and sort still work.
- Tree view: renders a collapsible
<details>tree outside the table. Compact for browsing, but no row actions.
The bundle is framework code only — no entity changes required.
Requirements
- PHP 8.2 or newer
- Symfony 7.0 or newer
easycorp/easyadmin-bundle4.25 or newer
Installation
composer require ghaliano/easyadmin-grouping-bundle
If Symfony Flex doesn't auto-register the bundle, add it manually to
config/bundles.php:
return [ // ... Ghaliano\EasyAdminGrouping\EasyAdminGroupingBundle::class => ['all' => true], ];
Publish the static assets (run once after install and on each update):
php bin/console assets:install public --symlink
Quick start
use Ghaliano\EasyAdminGrouping\Application\GroupingConfig; use Ghaliano\EasyAdminGrouping\Infrastructure\EasyAdmin\GroupableCrudControllerTrait; use Ghaliano\EasyAdminGrouping\Infrastructure\EasyAdmin\GroupingActionHelper; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; class UserCrudController extends AbstractCrudController { use GroupableCrudControllerTrait; public static function getEntityFqcn(): string { return User::class; } public function configureGrouping(): GroupingConfig { return GroupingConfig::create() ->groupByProperty('department.name', 'Department'); } public function configureActions(Actions $actions): Actions { return $actions->add( Action::INDEX, GroupingActionHelper::groupedTableAction() ); } }
Open the index page: a "Grouped view" button appears in the action bar. Click it and the rows are sorted by department, with a header row between each department block.
Grouping strategies
A strategy maps an entity to one or more group keys. Two built-in strategies, plus a custom strategy slot for anything else.
By property path
Reads a Symfony PropertyAccessor path on the entity. Dotted paths
traverse associations. Returns null for missing or empty values
(the row falls into a configurable "Uncategorized" bucket).
->groupByProperty('profession.name', 'Profession')
By callable
For computed values, complex enums, or method calls.
->groupByCallback( fn(User $u) => $u->isActive() ? 'Active' : 'Inactive', 'Status' )
The callable may return:
- a
string— the item joins one group - a
string[]— the item appears in each group (think tags, roles) null— the item lands in "Uncategorized"
Multi-level (nested)
Chain calls to nest groups. The first strategy is the outer group.
return GroupingConfig::create() ->groupByProperty('department.name', 'Department') ->groupByCallback(fn(User $u) => $u->getStatus()->label(), 'Status');
Each level renders its own header row with distinct indentation and colour. Clicking a header collapses the entire subtree.
Custom strategy
When neither groupByProperty() nor groupByCallback() fits — for
example you want to group by month from a date field, by first letter
for an A–Z directory, or by a computed score bucket — implement
Ghaliano\EasyAdminGrouping\Domain\GroupingStrategy directly:
$config->groupBy(new MyCustomStrategy());
The interface contract is intentionally minimal so you can plug in any extraction logic without surprises. A step-by-step tutorial with three worked examples (month grouping, first-letter directory, multi-valued tags) is in docs/custom-strategies.md.
Action UI: buttons or dropdown
Two ways to expose the "switch to grouped view" affordance.
Separate buttons (one per variant)
return $actions ->add(Action::INDEX, GroupingActionHelper::groupedTableAction('By code', 'code')) ->add(Action::INDEX, GroupingActionHelper::groupedTableAction('By status', 'status'));
The controller's configureGrouping() reads the groupBy query param
to choose which strategy chain to build.
Single dropdown
One button opens a small menu listing all variants. Lighter in the action bar when you have more than two variants.
return $actions->add(Action::INDEX, GroupingActionHelper::groupedTableSelect([ 'code' => 'By code', 'status' => 'By status', 'both' => 'Code, then status', ], label: 'Group by'));
The dropdown requires the bundle's small action JS to be loaded on every
admin page. Add it to your DashboardController:
public function configureAssets(): Assets { return Assets::new() ->addJsFile('bundles/easyadmingrouping/grouping-actions.js'); }
Reading groupBy from the URL in configureGrouping():
public function configureGrouping(): GroupingConfig { $by = $this->container->get('request_stack') ->getCurrentRequest() ?->query->get('groupBy', 'code'); $config = GroupingConfig::create(); return match ($by) { 'status' => $config->groupByCallback($byStatus, 'Status'), 'both' => $config->groupByCallback($byCode, 'Code') ->groupByCallback($byStatus, 'Status'), default => $config->groupByCallback($byCode, 'Code'), }; }
Disabling column sort on grouping fields
Clicking a column header to sort a field that already drives the grouping
just flips the group order — confusing for users. Disable the column
sort with the property name shown in EA's <th data-column="...">:
return GroupingConfig::create() ->groupByCallback($byCode, 'Code') ->disableSortFor(['code']);
The header label stays visible but the link is removed and the cursor becomes default. Other columns remain sortable.
Configuration reference
| Method | Effect |
|---|---|
groupByProperty($path, $label) |
Group by Symfony property path |
groupByCallback($fn, $label) |
Group by callable |
groupBy(GroupingStrategy) |
Group by custom strategy |
collapsedByDefault(bool) |
Initial collapsed state |
showCounts(bool) |
Show item count per group header |
uncategorizedLabel(string) |
Bucket label for null keys |
sortGroupsBy(SORT_ALPHA | SORT_COUNT_DESC) |
Group ordering |
withGroupSorter(GroupSorter) |
Custom group sorter |
itemLabelFromProperty($path) |
Item label in tree view |
itemLabelFromCallback($fn) |
Item label in tree view |
rootGroupsPerPage(int) |
Tree view pagination size |
itemLinkAction(?string) |
EA action a row click navigates to (null = read-only) |
disableSortFor(string[]) |
Disable column sort for these EA field names |
How the table view works
The trait sets the index template to one that extends EA's
crud/index.html.twig — or the controller's overridden template if
there is one, so custom modals and content_footer blocks survive.
The template overrides the entity_row_attributes block to emit a
data-easyadmin-group-keys attribute on each <tr>. After the page loads,
grouped-table.js:
- Reads the keys from each row.
- Sorts rows by keys (stable, multi-level, direction inherited from
EA's current
?sort=DESC). - Inserts header
<tr>elements between groups. - Binds collapse and expand handlers, persisting state in
localStoragekeyed by URL path.
EA's pagination is preserved. Grouping operates within the current page: if a group spans pages you will see partial groups, which the labels make obvious.
How the tree view works
The trait bypasses EA's paginator entirely. It runs the same
createIndexQueryBuilder() to honour filters and search, fetches all
matching entities, then:
- Builds a
GroupNodetree using the configured strategies and group sorter. - Paginates the root groups (configurable via
rootGroupsPerPage). - Renders
grouped_index.html.twigwith<details>nodes.
Tree view fetches the entire matching set in one query. It is fine up to a few thousand rows; past that, prefer the table view.
Design decisions
Some of the choices behind the code that aren't obvious from the API alone. They are listed here so contributors can argue with them with context, not in the dark.
Strategy as an interface, not a class hierarchy. PropertyPathStrategy
and CallableStrategy share nothing structurally — one calls
PropertyAccessor, the other invokes a closure. Forcing them under an
abstract parent would have been ceremonial. The interface exposes only
extractKey() and levelLabel(); implementations are free to do
whatever fits.
null means "unclassified", an array means "belongs to multiple
groups". Those semantics live in the interface PHPDoc, not in
special-case wrapper classes. It is explicit, and lets users write
their own strategy without surprises. The bucket routing and item
duplication logic stays in one place: GroupTreeBuilder::buildLevel().
Fluent builder over immutable config. EA's own DSL is fluent
(Crud::new()->setX()->setY()); admin developers expect that idiom.
GroupingConfig is mutable, but its lifecycle is short: created in
configureGrouping(), consumed by the trait, discarded. The bookkeeping
cost of an immutable withX() builder would not have bought anything
here.
Tree view and table view coexist. After the pivot to the table view, we could have dropped the tree view. It stayed: it is a different UX, useful for compact browsing of hierarchical reference data (think a tree of legal codes). Both modes share the entire Domain and Application layers — that is the payoff of the separation.
Decoration over replacement. This is the most impactful decision.
The alternative would have been recreating an index template from
scratch, duplicating actions, filters, sort, and re-syncing on every
EA release. Decorating ({% extends '@!EasyAdmin/crud/index.html.twig' %}
plus a handful of targeted block overrides) is what Twig was designed
for.
Client-side post-processing instead of a server-side sort. In table mode, rows arrive paginated by EA. Sorting them by group keys server-side would mean bypassing pagination and loading everything into memory — which is what tree mode does, and precisely why it does not scale. Table mode sorts the current page in JS. Trade-off: a group spanning two pages appears in two pieces. Documented honestly in Limitations. The compromise prioritises performance and clean integration with EA's pagination.
Collapse state in localStorage, not in the URL or cookies. A
URL-encoded state would be shareable but blows up past 20 groups. A
cookie would round-trip to the server on every request for no reason.
localStorage is client-only, keyed by window.location.pathname —
each CRUD gets its own state.
Limitations
- Sort within a group is not meaningful in either view. Groups always reorder rows first; column sort only flips group direction.
- Tree view loads all matching rows; do not enable it for very large datasets.
- Multi-valued strategies (returning
string[]) intentionally duplicate the row across groups. - The dropdown UI requires the action JS to be globally loaded; the per-button variant does not.
Running the tests
cd vendor/ghaliano/easyadmin-grouping-bundle
composer install
vendor/bin/phpunit
The test suite covers the domain (strategies, sorting, group nodes)
and the application service (GroupTreeBuilder, GroupingConfig). It
does not boot Symfony — pure unit tests.
License
MIT.