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.

Maintainers

Package info

github.com/ghaliano/easyadmin-grouping-bundle

Type:symfony-bundle

pkg:composer/ghaliano/easyadmin-grouping-bundle

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-05-18 17:10 UTC

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-bundle 4.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:

  1. Reads the keys from each row.
  2. Sorts rows by keys (stable, multi-level, direction inherited from EA's current ?sort=DESC).
  3. Inserts header <tr> elements between groups.
  4. Binds collapse and expand handlers, persisting state in localStorage keyed 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:

  1. Builds a GroupNode tree using the configured strategies and group sorter.
  2. Paginates the root groups (configurable via rootGroupsPerPage).
  3. Renders grouped_index.html.twig with <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.