PHPackages                             ghaliano/easyadmin-grouping-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Admin Panels](/categories/admin)
4. /
5. ghaliano/easyadmin-grouping-bundle

ActiveSymfony-bundle[Admin Panels](/categories/admin)

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.

v0.1.0(3w ago)02MITPHPPHP &gt;=8.2CI passing

Since May 18Pushed 3w agoCompare

[ Source](https://github.com/ghaliano/easyadmin-grouping-bundle)[ Packagist](https://packagist.org/packages/ghaliano/easyadmin-grouping-bundle)[ Docs](https://github.com/ghaliano/easyadmin-grouping-bundle)[ RSS](/packages/ghaliano-easyadmin-grouping-bundle/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (4)Versions (2)Used By (0)

ghaliano/easyadmin-grouping-bundle
==================================

[](#ghalianoeasyadmin-grouping-bundle)

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 `` tree outside the table. Compact for browsing, but no row actions.

The bundle is framework code only — no entity changes required.

Requirements
------------

[](#requirements)

- PHP 8.2 or newer
- Symfony 7.0 or newer
- `easycorp/easyadmin-bundle` 4.25 or newer

Installation
------------

[](#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
-----------

[](#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
-------------------

[](#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

[](#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

[](#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)

[](#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

[](#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](docs/custom-strategies.md).

Action UI: buttons or dropdown
------------------------------

[](#action-ui-buttons-or-dropdown)

Two ways to expose the "switch to grouped view" affordance.

### Separate buttons (one per variant)

[](#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

[](#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
----------------------------------------

[](#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 ``:

```
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
-----------------------

[](#configuration-reference)

MethodEffect`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 namesHow the table view works
------------------------

[](#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 ``. 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 `` 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
-----------------------

[](#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 `` 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
----------------

[](#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
-----------

[](#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
-----------------

[](#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
-------

[](#license)

MIT.

###  Health Score

36

—

LowBetter than 79% of packages

Maintenance95

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity36

Early-stage or recently created project

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

22d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e5f7a44345ddfe37590c8c99263db06b34a9b164a2558c07131838ddaf164844?d=identicon)[ghaliano](/maintainers/ghaliano)

---

Tags

symfonycrudadmingroupingSymfony Bundleeasyadmin

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/ghaliano-easyadmin-grouping-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/ghaliano-easyadmin-grouping-bundle/health.svg)](https://phpackages.com/packages/ghaliano-easyadmin-grouping-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.5M370](/packages/easycorp-easyadmin-bundle)[2lenet/crudit-bundle

The easy like Crud'it Bundle.

1715.6k12](/packages/2lenet-crudit-bundle)[web-auth/webauthn-framework

FIDO2/Webauthn library for PHP and Symfony Bundle.

51090.8k2](/packages/web-auth-webauthn-framework)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M195](/packages/sulu-sulu)[rcsofttech/audit-trail-bundle

Enterprise-grade, high-performance Symfony audit trail bundle. Automatically track Doctrine entity changes with split-phase architecture, multiple transports (HTTP, Queue, Doctrine), and sensitive data masking.

1155.2k](/packages/rcsofttech-audit-trail-bundle)[arkounay/quick-admin-generator-bundle

QAG is a bundle that generates cruds admin for Symfony applications using Doctrine.

2511.2k](/packages/arkounay-quick-admin-generator-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
