Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sortable Tree Bundle Laravel Package

mikeevstropov/sortable-tree-bundle

Symfony bundle for building sortable, hierarchical trees from Doctrine entities. It provides tree management, reordering, and nested-set support for nested menus, categories, and other ordered structures with minimal configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

This is an archived Symfony 2/3 bundle (last release August 2016, 2 stars, zero dependents) that adds drag-and-drop reordering to an admin tree listing. Treat it as a reference implementation to copy from rather than a dependency you plan to maintain. If you are on Symfony 4+ or Flex, budget time to either fork it or reimplement the ~2 files that matter.

Minimum steps

  1. Install (the package is published under the mev vendor prefix):
    composer require mikeevstropov/sortable-tree-bundle
    
    If Packagist resolution fails on a modern PHP constraint, pin the repo directly:
    "repositories": [{ "type": "vcs", "url": "https://github.com/mikeevstropov/mevSortableTreeBundle" }]
    
  2. Register the bundle in app/AppKernel.php (there is no Flex recipe):
    new Mev\SortableTreeBundle\MevSortableTreeBundle(),
    
  3. Import its routing in app/config/routing.yml:
    mev_sortable_tree:
        resource: "@MevSortableTreeBundle/Resources/config/routing.yml"
        prefix:   /admin
    
  4. Publish assets: php app/console assets:install --symlink web.
  5. You need an entity that already has an ordering concept — a position integer, or a nested-set/tree entity managed by Gedmo's Tree extension.

Where to look first

  • Controller/ — the AJAX endpoint that receives the serialized tree and persists it. This is the whole bundle.
  • Resources/public/js/ — the JS plugin integration (jQuery-era, likely nestable/jstree-based).
  • Resources/views/ — the Twig fragment you override to inject the JS and data attributes.
  • DependencyInjection/Configuration.php — the only real config surface: entity class, position field, parent field.

First use case

Override the list template of an existing admin tree screen so rows carry data-id attributes, include the bundle's JS, and point the JS at the bundle's route. On drop, the bundle POSTs a nested JSON structure of ids; the controller walks it and updates position (and parent, if you configured one). Verify by dragging one node, checking the network tab for the payload, then confirming the new order survives a page reload.

Implementation Patterns

Nested-set entity (the clean path). Let Gedmo manage lft/rgt/root; configure the bundle to only set the parent relationship, and let Tree listeners recompute the rest on flush. Do not write lft/rgt yourself unless you disable the listeners.

# config.yml
mev_sortable_tree:
    entity: AppBundle\Entity\Category
    position_field: position
    parent_field: parent

Plain position column (the fragile path). The controller walks the submitted tree and calls setPosition($i++) on each node. This works for a single-level list, but on multi-level trees you must also rewrite positions relative to each parent, otherwise siblings from different branches collide.

Wiring into Sonata Admin. The common integration is a custom list block:

// Admin/CategoryAdmin.php
public function getTemplate($name)
{
    if ($name === 'list') {
        return 'AppBundle:CategoryAdmin:list.html.twig';
    }
    return parent::getTemplate($name);
}

Then in the override, {% extends 'SonataAdminBundle:CRUD:base_list.html.twig' %} and add the bundle's JS plus a hook on the table body. The bundle does not ship an Admin class or a Sonata block, so the template glue is on you.

Controller endpoint shape. Assume a POST of JSON like [{ "id": 1, "children": [{ "id": 3 }] }]. Wrap the whole reorder in a single transaction and one flush():

$em->transactional(function ($em) use ($payload) {
    $this->treeSorter->apply($payload); // recursive walk, setParent + setPosition
});

Batch updates without N+1. Collect the ids from the payload, load them in one findBy(['id' => $ids]), index by id in PHP, then walk the payload against that map. A naive recursive find() per node is the usual reason this endpoint takes 2+ seconds on a 500-node tree.

Security hook. The bundle's route has no voter. Either wrap it in your own controller that checks $this->isGranted('EDIT', $entity) per node, or add a kernel.request listener scoped to the route name. Sorting a tree is a write operation — treat it like one.

Idempotent payload contract. Define and document what the JS sends (ids only? ids + parent? full nested structure?) and keep the controller defensive: reject nodes whose id is not in the allowed set for the current admin, reject cycles, reject unknown keys.

Gotchas and Tips

Archived means frozen. No Symfony 4/5/6 support: no services.yaml autowiring, no AbstractBundle, no Configuration tree with ->end() modern defaults. On a modern stack the bundle's service definitions often fail to compile outright. Fork it, convert the DI extension, and register services explicitly — it is roughly 30 minutes of work for two classes.

PHP version drift. 2016-era code predates PHP 7.4 typed properties and PHP 8's stricter ArrayAccess/Countable signatures. Expect deprecation noise at minimum; expect fatal errors if the code uses each(), create_function(), or unguarded count(null).

assets:install vs Flex. There is no recipe; the JS is served from web/bundles/mevsortabletree/. If you are on Symfony 4+, either run bin/console assets:install into public/ or copy the JS into your own asset pipeline (Encore/Vite). Do not reference the bundle path from Twig without confirming the file was actually published.

Doctrine ordering and the position field. If your repository orders by position and the reorder writes duplicate positions mid-flush, MySQL's unique constraint (if you have one on parent_id, position) will blow up. Either drop the unique constraint, or update in two passes: offset all positions into a negative/scratch range, flush, then write finals.

Nested-set churn. With Gedmo's Tree, every setParent() triggers node moves inside the listener. Bulk reordering a large tree this way is O(n²) in queries. For big trees, prefer moveUp/moveDown relative operations if the payload allows it, or rebuild the whole subtree with Tree::buildTree() in a transaction.

Deadlocks under concurrent drags. Two admins dragging overlapping subtrees will deadlock on InnoDB. Lock the root node (SELECT ... FOR UPDATE on the tree root) at the start of the reorder, or serialize per-tree with a cache lock.

CSRF is not handled for you. Add a token to the JS request and validate it in the controller. The bundle predates Symfony's CsrfTokenManager becoming the default assumption in admin bundles.

Debugging the payload mismatch. The most common failure is a shape mismatch between what the JS plugin emits and what the controller expects — the plugin's onChange callback serializes only ids, while the controller wants {id, parent}. dump(file_get_contents('php://input')) inside the controller, or log the raw body, before touching the persistence logic. Symptom is a 200 with no rows changed.

Extension points worth using. Override templates via app/Resources/MevSortableTreeBundle/views/... (Symfony 2/3 convention). Override the sorter service by defining an alias with the same id in your services.yml. Add your own kernel.event_listener on doctrine.eventListener if you need to normalize positions after any write, not just the drag endpoint.

If you are coming from Laravel. The mental model maps cleanly: this bundle's job is exactly what kalnoy/nestedset's saveTree() plus a SortableJS frontend does. On a greenfield project the pragmatic path is a position column with ORDER BY position, a POST /reorder endpoint taking a flat id list, and SortableJS — no bundle, no archived dependency, and it ports to either framework unchanged.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr
laravel2580/idehelpers
delatbabel/viewpages
anavel/gettext
muhamadrezaar/highcharts
delatbabel/keylists
frankkessler/salesforce-laravel-oauth2-rest
anavel/foundation
wdmtech/enviricons
eii/laravel-captcha
muka/shape-reader
tdt/json