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

Baum Laravel Package

yajra/baum

yajra/baum adds nested set trees to Laravel Eloquent, so you can store and query hierarchical data like categories and menus. Create, move, and reorder nodes and fetch ancestors, descendants, and siblings efficiently through an intuitive ActiveRecord-style API.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install (and confirm it actually installs). composer require yajra/baum. Before writing a line of code, check what Composer resolved — this package descends from the Laravel 4/5-era etrepat/baum, so on Laravel 10+/PHP 8.2+ you may hit constraint failures. If it installs, immediately run a throwaway create → move → delete script against the real stack. If Composer refuses or the smoke test breaks, stop: the decision you are actually making is "vendor/fork a few hundred lines" vs. "use kalnoy/nestedset", not "configure a package."

2. Add the tree columns. Baum requires parent_id, lft, rgt, depth in that table. Index all four, and use a wide integer type — lft/rgt are global counters, so size them for the largest tree you will ever have.

Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->unsignedBigInteger('parent_id')->nullable()->index();
    $table->unsignedBigInteger('lft')->nullable()->index();
    $table->unsignedBigInteger('rgt')->nullable()->index();
    $table->unsignedInteger('depth')->nullable()->index();
    // multi-tree: $table->unsignedBigInteger('company_id')->index();
    $table->timestamps();
});

3. Make the model a node. Extend Baum\Node instead of Model, and do not put lft, rgt, or depth in $fillable.

class Category extends \Baum\Node
{
    protected $table = 'categories';
    protected $fillable = ['name', 'parent_id']; // parent_id only if you set it deliberately
}

4. Plant the first tree.

$root = Category::create(['name' => 'Catalog'])->makeRoot();
$electro = Category::create(['name' => 'Electronics', 'parent_id' => $root->id]);
// save() on an unsaved node with parent_id set appends it as the last child

Where to look first: open src/Baum/Node.php and read it end to end — it is small, and every gotcha below lives in that one file. Then the README's API list for the relation/scoping helpers. Do this before you build anything; you will be the maintainer whether you planned to be or not.

First real use case: a category or nav-menu tree rendered in one query. Category::orderBy('lft')->get()->toHierarchy() gives you the whole forest with no recursion and no N+1, which is exactly the win you are paying the write amplification for.

Implementation Patterns

Read with relations, not recursive queries. Baum gives you query-returning methods and lazily-loaded relations that share names — this trips people up daily.

$node->children;              // Collection, already loaded
$node->children()->get();     // re-query, chainable
$node->children()->where('active', true)->get();

$node->descendants()->pluck('id');   // one query, whole subtree
$node->ancestors()->get();           // one query, breadcrumb trail
$node->siblings()->get();

Chainable modifiers: withDepth(), limitDepth($n), withoutSelf(), withoutNode($node), withoutRoot(), ordered(), reversed(). limitDepth(2) is the workhorse for "show top two levels in the sidebar" without hydrating the rest.

Render a tree with toHierarchy(). Load once, ordered by lft, then recurse in Blade over the in-memory children relation:

$tree = Category::orderBy('lft')->get()->toHierarchy();
@foreach ($tree as $node)
    <li>{{ $node->name }}
        @if ($node->children->isNotEmpty())
            <ul>@include('partials.tree', ['tree' => $node->children])</ul>
        @endif
    </li>
@endforeach

Populate a <select> with getNestedList(). This is the single most-used Baum call in admin panels:

$options = Category::getNestedList('name', null, '— ');
// [1 => 'Catalog', 2 => '— Electronics', 3 => '— — Phones', ...]

Create/update from a form. Treat parent_id as a positioning instruction, not a column write. On create it is safe (Baum appends to the end of the parent's children). On update, never assign parent_id and save — call a move method instead.

// create
$category = Category::create($validated);   // parent_id in $validated

// re-parent an existing node
DB::transaction(function () use ($category, $newParent) {
    if ($category->isDescendantOf($newParent)) {
        throw new \RuntimeException('Cannot move a node into its own subtree.');
    }
    $category->makeChildOf($newParent);
});

Drag-and-drop ordering. moveToLeftOf($sibling) / moveToRightOf($sibling) for same-level reorder, makeChildOf($parent) / makeFirstChildOf / makeLastChildOf for re-parenting. Every one of these is an O(n) range update: wrap in a transaction, and serialize concurrent writes per tree (a Cache::lock("tree:{$scopeId}") around the move is cheap insurance).

Scoped trees for multi-tenancy. Declare the scope column on the model so each tenant gets an independent lft/rgt numbering, and keep it immutable after creation — changing a node's scope silently corrupts both trees.

protected $fillable = ['name', 'company_id'];
protected $scope = ['company_id'];   // property name varies by fork — confirm in Node.php

Always query through the scoped model, and be suspicious of raw lft/rgt comparisons in report queries — they will happily cross tenant boundaries if you forget the scope clause.

Seed and import with rebuild(). For nested arrays from a CSV or a legacy export, Baum\Node::rebuild($tree) (passing the column names if they differ) builds the whole structure in one pass — far faster and safer than create() in a loop.

Cache subtrees, invalidate on move. A nav menu is the classic candidate: cache Category::orderBy('lft')->get()->toHierarchy() keyed per scope, and flush on the moving/moved model events. Verify the event names fire in your fork before you wire a cache to them.

Guard every mutation with invariants in tests. Baum\Node::isValidNestedSet() is your assertion. Seed a fixture tree in RefreshDatabase, exercise create/move/delete/scoped-move, and assert validity after each. Add the same check as a read-only scheduled job in staging — silent range corruption is the failure mode you cannot detect without it.

Gotchas and Tips

  • parent_id and save() are not a move. save() positions a new node; on an existing node it leaves lft/rgt stale and the tree corrupt. Only the move methods reposition. Put this in a code review checklist.
  • delete() semantics differ by fork. Baum overrides Eloquent's delete() to repair ranges, and some versions cascade to descendants. Read Node::delete() in your vendored copy and pin the behavior with a test before it runs in production on a root node.
  • Soft deletes are the classic corruption source. Trashed rows still occupy lft/rgt ranges; scope clauses that exclude them produce gaps and broken traversals. If you need soft deletes, decide up front whether they participate in the numbering and test the interaction explicitly — or skip SoftDeletes on tree models entirely.
  • Concurrency is your problem, not the package's. Two simultaneous inserts under the same parent will race. DB::transaction() plus a per-tree lock, or accept that drag-and-drop in a busy admin needs to be queued.
  • Validate the move target. Moving a node into its own descendant throws or returns false depending on the path; check isDescendantOf() yourself and return a clean 422 rather than a stack trace.
  • N+1 hides in plain sight. $node->children is fine; calling $child->children inside a loop is not. Load once with toHierarchy().
  • getNestedList() hydrates the whole tree. Fine for hundreds of categories, not for a 50k-row taxonomy — fall back to a depth-limited query or an AJAX search.
  • Ordering assumptions. Everything relies on ORDER BY lft. Any custom query that omits it will render in arbitrary order, and toHierarchy() on an unordered collection produces a plausible-looking but wrong tree.
  • Index/type sizing. lft/rgt grow to 2 × node count. Use unsignedBigInteger if the tree can exceed ~2 billion, and add a composite index on (scope_column, lft, rgt) for scoped models.
  • Composer constraints are a symptom. If you had to --ignore-platform-reqs or pin an old Laravel to install this, that is the maintenance bill arriving early. Fork it into app/Support/Baum or a private package, pin the constraints explicitly, and assign a named owner — the code is small enough that this is bounded work, but it is work.
  • Never trust the tree after an interrupted write. A crashed move leaves overlapping ranges that surface weeks later as missing menu items. Run isValidNestedSet() in CI after your fixture tests, and make repairs a scripted operation, not a manual SQL session.
  • Upgrade path. If this is greenfield or you are on Laravel 10+, evaluate kalnoy/nestedset first — same pattern, same mental model, current maintenance. The migration cost is mostly method renames; the cost of staying is a dependency you own alone.
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.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager