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

toponepercent/baum

Baum is a Nested Set implementation for the Laravel Eloquent ORM, making it easy to store, traverse and query hierarchical data such as categories, menus and trees with efficient single-query reads. Supports Laravel 5.8 through 13.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and scaffold in three commands. Baum is a Composer-only package with no service provider or config file to publish, so onboarding is fast:

composer require toponepercent/baum
php artisan make:baum Category
php artisan migrate

The make:baum generator is the real entry point: it writes a model already extending Baum\Node plus a migration containing parent_id, lft, rgt, and depth. Read both generated files before you touch them — the generator's comments are the closest thing to inline documentation for column customization.

Where to look first. The README's "Migration configuration" and "Model configuration" sections are the two you cannot skip. Everything else is API reference. Confirm four things before writing a single line of business logic:

  1. The migration has parent_id, lft, rgt, depth as integers (nullable is the package default; see Gotchas).
  2. Your model extends Baum\Node, not Model.
  3. $guarded includes parent_id, lft, rgt, depth so nobody mass-assigns structural columns.
  4. If the column names differ from the defaults, they're overridden in the model and match the migration exactly.

First use case to validate the integration. A category or menu tree is the canonical fit, and it exercises every primitive you'll use daily. In php artisan tinker:

$root  = Category::create(['name' => 'Root']);
$child = Category::create(['name' => 'Child']);
$child->makeChildOf($root);

$root->getDescendants();      // one query, no recursion
$child->getAncestors();       // breadcrumb chain
$root->children;              // relation
$child->getLevel();           // 1

If getDescendants() returns the child and getLevel() returns 1, the schema and model agree and you're ready to build. If either is wrong, the column mapping is off — fix that before proceeding, because every subsequent bug will look like a logic bug when it's actually a mapping bug.

Decide early whether a nested set is right. The README is unusually honest: choose nested sets when the node is essentially one or two attributes plus tree structure, and when reads vastly outnumber writes. Menus, category trees, threaded comments, org charts, and permission hierarchies qualify. A tree node carrying ten relations, per-node workflow state, and frequent re-parenting does not — the write cost of shifting lft/rgt ranges across the whole table will dominate.

Implementation Patterns

Wrap every structural mutation in a transaction. This is the single most important habit with Baum. A move rewrites the left/right bounds of potentially every row involved, and the package's own move logic issues multiple statements. Even if a single call is internally safe, a controller that performs two moves should not be able to leave the tree half-updated:

DB::transaction(function () use ($node, $target) {
    $node->makeChildOf($target);
});

Put this in a small domain service (TreeService) rather than scattering it through controllers — you'll want one place to add retries on deadlock later.

Model parent changes through parent_id, not raw updates. Baum hooks the save cycle, so $node->parent_id = $other->id; $node->save(); and $node->makeChildOf($other) both perform a proper move, and setting parent_id = null behaves like makeRoot(). Use the explicit make* methods in application code anyway — they read better in diffs and they make the intent ("reorder siblings" vs "re-parent") obvious to the next developer.

Ordering is lft, always. Sibling order in a nested set is encoded in the left bounds; there is no position column to sort by. Whenever you fetch a collection that will be displayed or serialized, add ->orderBy('lft'). The children() relation and the tree helpers generally already do this, but hand-written where() chains will not, and unordered trees produce menus that reshuffle between requests.

Breadcrumbs and ancestor chains come free. $node->getAncestors() is one indexed query regardless of depth — use it directly for breadcrumb rendering and for "you are here" navigation. Don't build a recursive walker; that's the whole point of the pattern.

Use getNestedList() for indented <select> inputs. For admin forms where a node is reparented via a dropdown, this is the fastest path to a correctly indented option list. Combine with a "not self or descendant" guard on the server so a user can't pick an invalid target; Baum will throw on the move, but you want a validation message, not a 500.

Cache the rendered tree, invalidate on events. A menu is read on every page and changes maybe weekly. Serialize Category::toHierarchy() (or your own flat-to-tree transform) into your cache store, and bust it from model events. Baum fires moving/moved on the node being moved — register a listener in a service provider:

Category::moved(fn () => Cache::forget('category.tree'));
Category::saved(fn () => Cache::forget('category.tree'));
Category::deleted(fn () => Cache::forget('category.tree'));

Note the caveat in Gotchas about descendant events on delete.

Build trees in PHP from a flat indexed fetch for APIs. toHierarchy() eager-loads recursively and is fine for moderate trees, but for a JSON endpoint serving a deep or wide tree, fetch Category::orderBy('lft')->get() once (one query, all rows) and nest it with a single pass keyed by parent_id. You get O(n) behavior, exactly one query, and full control over which columns are exposed.

Multi-tenant trees use $scoped. Declare protected $scoped = ['company_id']; so each tenant's tree has its own independent left/right numbering. Two consequences to design around: the scope attribute must be set on the model before saving (it isn't inferred from the session), and cross-scope moves are not meaningful — build your move endpoints so the target is always resolved through a query already constrained to the current tenant.

Bulk imports must go through the model or end with rebuild(). Seeding a tree from a CSV with DB::table()->insert() leaves lft/rgt meaningless. Either create nodes via the model and make* methods (slow for large sets) or insert flat and then run Category::rebuild() once at the end of the seeder. Do the same after any manual SQL fix-up in production.

Test the tree, not the ORM. Use RefreshDatabase and write assertions on structure, not just on rows: after a move, assert the target's getDescendants() contains the node, assert getLevel() changed, and assert the leaf/root scopes return the expected sets. A cheap invariant check catches corruption early:

// every node's lft < rgt, and bounds are unique within a scope
$this->assertSame(0, Category::whereColumn('lft', '>=', 'rgt')->count());

Gotchas and Tips

Mass assignment is a live footgun. If lft, rgt, depth, or parent_id are fillable, some future update($request->all()) will silently destroy your tree. Guard them in every Baum model, and be aware that $guarded = [] — a common Laravel shortcut — is effectively a bug with this package.

Freshly created nodes and stale in-memory instances. Two related traps. First, a node's lft/rgt/depth are only meaningful once persisted — check exists before relying on isLeaf(), getLevel(), or isDescendantOf(). Second, after any move, other loaded instances in the same request hold stale bounds, so isAncestorOf() between two objects can return the wrong answer. Call ->refresh() on anything you compare after a mutation, or re-query.

The truth-tellers are isDescendantOf/isAncestorOf, and they're in-memory. They compare lft/rgt values, not the database. That's fast and useful in guard clauses ("can't move a node into its own subtree"), but it means correctness depends entirely on the model instances being current. For a validation rule on user input, prefer a fresh query lookup.

Deletes cascade silently. Deleting a node removes its descendants and recalculates bounds, but the README explicitly notes that deleting/deleted events are not fired for those descendants. If you have side effects on delete — file cleanup, search index removal, audit records — they will be skipped for the whole subtree. Handle this with an explicit getDescendants() loop before the delete if those side effects matter.

Soft deletes need verification against your queries. Soft-deleted rows still occupy left/right space in the table. The default global scope hides them, which means getDescendantCount() and isLeaf() answers can disagree with what the user sees, and a soft-deleted node still blocks its subtree in the ordering. The README has a soft-deletes section — read it, then write a test that soft-deletes a mid-tree node and asserts what getDescendants() returns on the parent. Don't assume.

Concurrency and deadlocks. Nested-set writes are range updates over many rows. Two simultaneous moves touching overlapping ranges will deadlock under InnoDB. Wrap in DB::transaction() and add a retry on deadlock (DB::transaction($fn, 3) is enough in most apps). For an admin-only tree this never surfaces; for a user-facing reorder UI it will.

Indexes decide whether this pattern pays off. Add indexes on parent_id, lft, rgt, depth, and — critically for scoped trees — composite indexes prefixed with the scope column (e.g. ['company_id', 'lft']). Without them, "one fast query" degrades into a scan on a large tree.

Consider non-nullable columns for new tables. The classic Baum migration makes lft/rgt/depth nullable. For a greenfield table, make them non-nullable (or at least add ->index()) — it turns silent corruption into a constraint violation at insert time.

rebuild() is your safety net, not your routine. It rewrites the whole table's structure in one pass and gets you out of any corruption caused by raw SQL, failed imports, or an interrupted move. Schedule it in a maintenance command or a "repair tree" admin action. Don't call it on every request or in a hot path.

Custom sorting column: the README documents an order/sort column option for retrieval ordering. If you use it, understand it affects how results are returned, not the physical lft/rgt ordering that moves operate on — so a "sort alphabetically" feature and a "drag to reorder" feature cannot both drive sibling order. Pick one and make the other cosmetic.

Version and risk assessment. The README claims support from Laravel 5.8 through 13, which is impressive for a maintained fork, but the package has zero dependents on record and a small star count — the bus factor is essentially one maintainer. Pin the version in composer.json, keep the tree logic behind a thin service layer so it's swappable, and be prepared to evaluate kalnoy/nestedset (far larger ecosystem, different API) if the fork stalls on a future Laravel major. The upside of Baum is that its API is stable, tiny, and drop-in for legacy code — the downside is you're the integration test.

Debugging checklist when the tree looks wrong. Run these three queries first; they localize 90% of problems:

SELECT * FROM categories WHERE lft >= rgt;                 -- broken bounds
SELECT lft, COUNT(*) c FROM categories GROUP BY lft HAVING c > 1;  -- duplicate bounds (usually a missing $scoped)
SELECT * FROM categories ORDER BY lft;                      -- visual sanity: children between parent bounds

Duplicate lft values almost always mean a scoped model where the scope column wasn't set on some rows, or two root nodes were created in a context Baum thought was one tree. Fix with rebuild(), then fix the creation path.

Extension points worth knowing. Custom column names via $parentColumn/$leftColumn/$rightColumn/$depthColumn (model only — match the migration), multi-tree support via $scoped, and model events moving/moved for cache invalidation and audit logging. If you need a shared base model with your own app-wide behavior, create an intermediate class extending Baum\Node and have your tree models extend that — you can only have one parent class, so plan for it before you have ten Baum models.

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.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel