ttbooking/baum
Baum is a Laravel package that brings Nested Set trees to Eloquent models, making it easy to store, query, and manipulate hierarchical data such as categories or menus. It provides efficient ancestor and descendant lookups plus simple node movement.
v2.0.0 is a breaking release: the root namespace moved to TTBooking\Baum, so any model currently extending Baum\Node will fatal until updated. Treat the upgrade as a code-touch task, not just a composer update.
Install / upgrade
composer require ttbooking/baum:^2.0
Check the package's composer.json for the exact PHP/Laravel floor — v2 modernized the codebase and added static analysis, so expect current PHP 8.x and a recent Laravel major. Pin accordingly in CI.
Migrate the namespace first. Global search/replace across app/:
Baum\Node → TTBooking\Baum\NodeBaum\Move → TTBooking\Baum\MoveBaum\Extensions\... → TTBooking\Baum\Extensions\...Run php artisan list | grep baum before deploying anything scripted — the release removes the Baum command, so any install/deploy step that shelled out to it will break.
Add the nested-set columns to the target table if you're starting fresh (v2 pushes you to own the migration rather than generate it):
$table->unsignedBigInteger('parent_id')->nullable()->index();
$table->unsignedInteger('lft')->nullable()->index();
$table->unsignedInteger('rgt')->nullable()->index();
$table->unsignedInteger('depth')->nullable()->index();
// optional per-tree root column when scoping
$table->unsignedBigInteger('root_id')->nullable()->index();
Model
use TTBooking\Baum\Node;
class Category extends Node
{
protected $table = 'categories';
protected $fillable = ['name', 'parent_id', 'lft', 'rgt', 'depth'];
// override column names only if you renamed them
// protected $parentColumn = 'parent_id';
}
First use case — a category tree you can render as a menu:
$root = Category::create(['name' => 'Shop'])->makeRoot();
Category::create(['name' => 'Shoes'])->makeChildOf($root);
$tree = Category::getDescendantsAndSelf()->toHierarchy();
Look first at the model's column-name properties and the move methods — those are where 90% of day-to-day customization happens.
Tree building and moving. Nested set is write-heavy, so all structure changes go through Move, never raw attribute updates:
$node->makeRoot();
$node->makeChildOf($parent);
$node->moveToLeftOf($sibling);
$node->moveToRightOf($sibling);
$node->moveTo($target, 'child'); // or 'left' / 'right'
Wrap multi-node restructuring in DB::transaction() and consider Model::withoutEvents() for imports, then rebuild once.
Reading. Use the relationship-style scopes rather than hand-rolled where clauses:
$node->children()->get();
$node->descendants()->get();
$node->ancestors()->get();
$node->siblings()->get();
$node->getRoot();
Category::all()->toHierarchy(); // nested arrays/collections
Category::all()->toFlatTree(); // flat, ordered by lft
Scoped trees. Multi-tenant or per-company trees are supported via a scope column; set it consistently or you will silently merge trees:
Category::setScoped(['company_id' => $companyId]);
// or declare protected $scoped = ['company_id'];
Any query built outside the scope helper must re-apply the scope manually.
Rendering / UI. Persisted depth makes menu rendering cheap — query where('depth', '<=', 2)->orderBy('lft') in one shot instead of recursing. For recursive UI components and eager loading, with('children') plus toHierarchy() on an already-ordered set is the fastest path.
Bulk import. Create the flat records, then either assign parent_id and rebuild from the parent column, or insert with correct lft/rgt computed in PHP. Never insert rows with null lft/rgt and expect reads to work — run the rebuild step (whether that's a package method or your own) as part of the import job.
Search/index sync. If observers currently update a search index or cache on save, keep that listener but also call it explicitly after a structural rebuild — see the gotcha below.
Testing. The package suite now uses Orchestra Testbench; mirror that in your app with RefreshDatabase, and assert tree integrity after every move test (lft/rgt/depth consistency) rather than only asserting parent IDs.
TTBooking\ prefix applies to every exported class, including traits and query extensions. Miss one use Baum\... in a seeder or a Nova/Filament resource and you get a class-not-found only at runtime in that code path.baum:rebuild survived; if not, replace rebuild steps with a queued job or console command of your own that calls the model's rebuild path. Check php artisan list on a staging deploy.saved/moved maintained a denormalized field, cache, or search index, those side effects will simply not happen after a rebuild. Add an explicit post-rebuild hook.HasUuids) — if your nodes use UUIDs, the parent_id (and root) columns must be uuid/char(36), and the model needs $keyType = 'string', $incrementing = false. lft, rgt, depth remain integers regardless.lft/rgt. Serialize structural writes with a transaction plus a row lock on the tree root, or move them onto a single-queue job.lft/rgt/depth by hand in forms or seeders; mass-assignment guards should include them in $guarded. Corruption shows up as duplicated lft values — add an integrity test.toHierarchy() needs ordered input. Call it on results already ordered by lft, or on getDescendantsAndSelf(); otherwise siblings shuffle unpredictably.Node or override Move/query methods, expect stricter signatures and typed properties to surface previously-silent mismatches (return types, nullable params). Run PHPStan on your own tree after upgrading.lft/rgt unless you handle it; decide whether deletes are structural (rebuild) or leaves-only, and test both.lft, rgt, depth, parent_id (and any scope column) are indexed; ancestors()/descendants() are range scans on lft/rgt and degrade quickly on large flat tables.How can I help you explore Laravel packages today?