redcode/tree-bundle
RedCode TreeBundle adds hierarchical tree support to Symfony and Doctrine entities, making it easy to model and manage nested data such as categories, menus, and pages. It provides tree-aware entity handling, ordering, and integration with the Symfony framework.
Install and enable. The bundle is a thin integration layer over Sonata Admin, jsTree and Gedmo's Nested Set, so it is only useful once those three are already working.
composer require redcode/tree-bundle
Pin the version deliberately — the bundle predates Sonata Admin 4 and PHP 8 attributes, so a Composer update can pull in a Sonata major that breaks its controller/template hooks. Check composer.json constraints in the repo before composer update.
Register the bundle after SonataAdminBundle and the Doctrine extensions bundle:
// config/bundles.php
Redcode\TreeBundle\RedcodeTreeBundle::class => ['all' => true],
Import the routing (the jsTree widget calls its own AJAX endpoints, so this is not optional):
# config/routes.yaml
redcode_tree:
resource: "@RedcodeTreeBundle/Resources/config/routing.xml"
Where to look first. Four files tell you 90% of the contract:
Admin/AbstractTreeAdmin.php — the base admin you extend; its protected hooks control the node label, icon and which fields appear in the tree.Controller/TreeController.php — the tree, move, rename, create, delete actions the JS calls.Resources/views/Tree/*.html.twig — the template Sonata renders instead of list.html.twig; override these blocks for custom node markup.Resources/public/js/* — the jsTree config; this is where drag-and-drop, lazy loading and the endpoint URLs live.First use case. A Category entity with Gedmo Nested Set, then swap its admin's list view for the tree. That is the canonical use: taxonomy, menu and page hierarchies that are edited by humans rather than by code.
/**
* @Gedmo\Tree(type="nested")
* @ORM\Entity(repositoryClass="Gedmo\Tree\Entity\Repository\NestedTreeRepository")
*/
class Category
{
/** @Gedmo\TreeLeft @ORM\Column(type="integer") */ private $lft;
/** @Gedmo\TreeRight @ORM\Column(type="integer") */ private $rgt;
/** @Gedmo\TreeLevel @ORM\Column(type="integer") */ private $level;
/** @Gedmo\TreeRoot @ORM\Column(type="integer", nullable=true) */ private $root;
/** @Gedmo\TreeParent @ORM\ManyToOne(targetEntity="Category") */
private $parent;
}
Then:
class CategoryAdmin extends AbstractTreeAdmin
{
protected $baseRouteName = 'admin_category';
protected $baseRoutePattern = 'category';
}
and give it the tree as its model_manager/list handler per the bundle's docs, then open /admin/category/tree. If the tree renders flat, run gedmo:tree-recover on the entity — it means the lft/rgt columns are not a valid nested set yet.
Tree-as-list, not tree-as-datagrid. The tree replaces the Sonata list view for that admin, so proxy/scope/filter configuration is bypassed. The working pattern is to keep two admins for the same entity: one AbstractTreeAdmin for structure editing, one plain AbstractAdmin for flat search, filters, batch actions and exports. Route users by a dashboard tile rather than trying to force filters into the tree.
Custom node rendering. Don't edit the bundle's Twig — subclass the admin and point getListTemplate()/the tree template hook at your own template that extends the bundle's and overrides only the label/icon blocks. This survives Composer updates; editing vendor files does not.
Permission-driven editing. jsTree drag-and-drop is gated on Sonata's isGranted() checks (EDIT for move/rename, CREATE for add-child, DELETE for delete). If nodes render but won't drag, the cause is almost always an Admin::isGranted() or voter denial rather than a JS bug. Test by logging in as ROLE_SUPER_ADMIN first.
Programmatic moves should go through Gedmo, not SQL. The tree's source of truth is lft/rgt. For imports, seed data or queue workers:
$repo = $em->getRepository(Category::class);
$repo->persistAsLastChildOf($child, $parent); // or persistAsFirstChildOf / moveUp / moveDown
$em->flush();
If you have a parent relation, keep it in sync yourself in the same transaction — Gedmo maintains the nested set columns, and @Gedmo\TreeParent does not reliably keep the association consistent through every move path.
Reuse the tree elsewhere. The controller's JSON endpoint is a legitimate API for building a menu cache warmer, a public navigation partial, or a headless consumer. Fetch once, cache the serialized tree, and invalidate on the admin's post-persist/post-update/post-delete events.
Form integration. For the parent field on the edit form, use an EntityType ordered by lft, or Gedmo's tree form type if available in your version. Never let a user pick a descendant as a parent — add a getValidParents() query builder filter, because the bundle does not prevent that cycle for you.
Lazy loading for big trees. Above a few hundred nodes, set jsTree to lazy-load children per node rather than shipping the entire subtree. The bundle's default config sends the whole tree.
sonata-project/admin-bundle and gedmo/doctrine-extensions versions in composer.json and treat any upgrade as a manual port.Resources/public; under assets:install --symlink it works, but with Webpack Encore you must add the jsTree JS/CSS to webpack.config.js manually or the tree renders as an empty <ul>.bin/console debug:router | grep redcode and look at the Network tab for redirects, not just 500s.lft/rgt for a large part of the table. For a 50k-node category tree this makes drag-and-drop unusable; use closure or materialized-path instead, or restrict the tree admin to a small hierarchy.gedmo:tree-recover, and the lft/rgt columns must be indexed.@Gedmo\TreeRoot mapped, the bundle draws a synthetic root node. If you see duplicate synthetic roots or nodes appearing at the top level twice, root is likely NULL for some rows — make the column nullable and re-run recovery.TreeController and its model manager are services — override the service definition in your app config (or decorate it) rather than extending the class, so the bundle keeps its routing wiring. That is where you add auditing: log the before/after parent on each move.doctrine logs for a failing UPDATE on a NOT NULL level/lft column — those must never be nullable.translations/.How can I help you explore Laravel packages today?