1001menus/baum
Baum is a Laravel nested set package for hierarchical data such as menus and categories. It offers an Eloquent model with efficient tree queries, node movement, reordering, and ancestor/descendant helpers for building and maintaining nested structures.
Install and scaffold. The package is a fork of the classic baum/baum nested-set implementation, so the workflow mirrors it:
composer require 1001menus/baum
php artisan baum:install "App\Models\Category"
The command generates a migration and a model stub. Before trusting either, open vendor/1001menus/baum/composer.json and src/ to confirm the PSR-4 namespace (Baum\ in the upstream, possibly renamed in this fork) and the supported Laravel constraint — this fork has no visible dependents, so treat the version as unverified until you run the test suite.
Minimum schema the trait depends on:
Schema::create('categories', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('parent_id')->nullable()->index();
$table->unsignedInteger('lft')->nullable()->index();
$table->unsignedInteger('rgt')->nullable()->index();
$table->unsignedInteger('depth')->nullable()->index();
$table->string('name');
$table->timestamps();
});
Model:
namespace App\Models;
use Baum\Node;
class Category extends Node
{
protected $table = 'categories';
protected $fillable = ['name'];
// Override only if your legacy schema differs:
// protected $parentColumn = 'parent_id';
// protected $leftColumn = 'lft';
// protected $rightColumn = 'rgt';
// protected $depthColumn = 'depth';
protected $guarded = ['id', 'parent_id', 'lft', 'rgt', 'depth'];
}
First use case — a category/menu tree with cheap subtree reads:
$root = Category::create(['name' => 'Electronics']);
$phones = Category::create(['name' => 'Phones']);
$phones->makeChildOf($root);
$root->getDescendantsAndSelf(); // one indexed range query
$phones->getAncestors(); // breadcrumb
$root->fresh()->isValidNestedSet();
Where to look first in the source: src/Baum/Node.php (the trait's public API), SetValidator (integrity checks), SetBuilder (bulk import), and the query extension that adds whereDescendantOf / withoutSelf.
Create with placement, not with attributes. Never set lft/rgt/depth yourself; create the row then position it, and wrap multi-step moves in a transaction:
DB::transaction(function () {
$node = Category::create(['name' => 'Tablets']);
$node->makeLastChildOf($parent);
});
Placement helpers worth memorising: makeRoot(), makeChildOf(), makeFirstChildOf(), makeLastChildOf(), makeSiblingOf(), moveToLeftOf(), moveToRightOf(). Ordering is a function of lft, so "reordering" in an admin UI is really a sequence of these moves.
Reading trees. Use the collection extensions rather than recursive PHP:
// Nested structure for a JSON API / Blade partial
$tree = Category::whereNull('parent_id')->get()->toHierarchy();
// Flat, lft-ordered list for <select> with indentation from depth
$options = Category::orderBy('lft')->get()->toFlatTree();
// or: $node->getDescendantsAndSelf()->toFlatTree();
For subtree-scoped queries use the builder helpers so you stay in one SQL statement:
$node->getDescendants()->withoutSelf();
Category::whereDescendantOf($node)->where('active', true)->get();
$node->getImmediateDescendants(); // == $node->children
Breadcrumbs and levels. getAncestors() ordered by lft is already root-to-parent; $node->getLevel() (or $node->depth) drives indentation and permission inheritance.
Admin reorder endpoint. The classic pattern is target + position:
public function move(Category $node, Category $target, string $position)
{
DB::transaction(function () use ($node, $target, $position) {
match ($position) {
'before' => $node->moveToLeftOf($target),
'after' => $node->moveToRightOf($target),
'inside' => $node->makeLastChildOf($target),
};
});
Cache::tags('categories')->flush();
}
Multi-tenant / multiple independent trees. Baum supports multiple roots in one table, but by default they share a single global lft/rgt numbering space (getRoots() returns them ordered by lft). If each tenant needs its own independent tree, use the scoped-set feature — define protected $scope = ['company_id']; on the model — and verify in the source that the fork actually applies the scope to its internal UPDATE statements before relying on it. Otherwise give each tenant its own root and always query within whereDescendantOf($tenantRoot).
Bulk import. Looping makeLastChildOf() inside a transaction is correct but O(n) wide updates. For seeding, either use Baum\SetBuilder (check the exact constructor signature in src/Baum/SetBuilder.php) or build the tree in memory and write lft/rgt/depth in a single pass, then assert integrity with SetValidator.
Integrity as a test assertion. Make this part of your suite:
$this->assertTrue(Category::whereNull('parent_id')->first()->isValidNestedSet());
// Repair if a bad migration or manual SQL corrupted the tree
Category::rebuild(); // recalculate lft/rgt
Category::rebuild(true); // force, also recalculating depth
Caching. Trees are read-heavy and write-rare: cache toHierarchy() output keyed by root id and flush on any move/create/delete. Wrap the whole write path (not just the moved node) so sibling renumbering invalidates correctly.
makeChildOf can update dozens or thousands of rows. Always wrap moves in DB::transaction, take a lock on the root (or use Cache::lock) if reordering is concurrent, and be prepared for deadlocks under parallel writes — retry on DeadlockException.lft, rgt, depth, parent_id out of $fillable, and never use Model::insert() or raw UPDATEs on the table. They bypass tree maintenance entirely.updated will see the moved node but not the dozens of rows whose lft/rgt changed. Put cache invalidation in your own service layer, not in observers.$node->children and $parent->children hold pre-move collections. Call ->refresh() or unsetRelation('children') before re-reading.parent_id dangling and the set broken. Either forbid restoring partial subtrees or restore the whole root-to-node chain and run rebuild().parent_id IS NULL. Ensure the column is nullable, and be careful with 0/empty-string sentinels from a legacy schema.lft/rgt are unsigned integers. Deep or frequently-reshuffled trees are fine, but if you imported data from another system, rebuild() before shipping — mismatched types will silently truncate on MySQL strict mode.active) may or may not be applied to Baum's internal updates depending on how the fork builds its queries. Test moves across tenants explicitly rather than assuming isolation.whereDescendantOf is the fast path. where('lft', '>', $n->lft)->where('rgt', '<', $n->rgt) is what it compiles to; prefer it over fetching getDescendants() and filtering in PHP.composer.json and commit the lock file; if you build anything substantial on it, vendor the source into your own namespace so an abandoned upstream can't block a future Laravel upgrade. Watch for the Model::newQuery()-era API — newer Laravel prefers newEloquentBuilder(), and forks often patch only this.kalnoy/nestedset is the drop-in-equivalent with active support, and for read-mostly hierarchies on MySQL 8 / Postgres, recursive CTEs (staudenmeir/laravel-adjacency-list) remove the write amplification of nested sets entirely. Keep your tree logic behind a repository/service so that swap stays cheap.How can I help you explore Laravel packages today?