Installation
composer require baril/bonsai
Ensure your Laravel version matches the compatibility table.
Publish Migrations
php artisan vendor:publish --provider="Baril\Bonsai\BonsaiServiceProvider" --tag="migrations"
Run the migrations:
php artisan migrate
Define a Tree Model
Extend Baril\Bonsai\Tree and add tree() to your model:
use Baril\Bonsai\Tree;
class Category extends Tree
{
protected $treeType = 'categories'; // Optional: for multi-tree support
public function tree()
{
return $this->belongsToMany(
static::class,
'categories_closure',
'ancestor',
'descendant',
'id',
'id'
)->withPivot(['depth']);
}
}
First Use Case: Create a Root Node
$root = Category::create(['name' => 'Electronics']);
$root->save(); // Automatically sets as root
$parent = Category::find(1);
$child = $parent->children()->create(['name' => 'Phones']);
$node = Category::find(2);
$node->moveTo($parent, 'after'); // or 'before', 'first', 'last'
$parent = Category::find(1);
$descendants = $parent->descendants; // Includes all children, grandchildren, etc.
$node = Category::find(3);
$ancestors = $node->ancestors; // Array of parent nodes up to root
$node = Category::findByPath([1, 2, 3]); // Finds node with path [1 → 2 → 3]
$subtree = Category::find(5);
$subtree->detach(); // Removes all descendants
$subtree->attachTo($newParent);
class Category extends Tree
{
public function getChildrenAttribute()
{
return $this->children()->where('is_active', true)->get();
}
}
$treeType to manage separate trees (e.g., categories, tags).Add custom scopes to your model:
class Category extends Tree
{
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeWithChildren($query)
{
return $query->with('children');
}
}
Usage:
$activeCategories = Category::active()->withChildren()->get();
Cache frequent tree queries:
$root = Category::find(1);
Cache::remember("category_{$root->id}_subtree", now()->addHours(1), function () use ($root) {
return $root->descendants;
});
Serialize trees for APIs:
public function toArray()
{
return [
'id' => $this->id,
'name' => $this->name,
'children' => $this->children->map(fn ($child) => $child->toArray()),
];
}
Listen for tree changes:
class CategoryObserver
{
public function saved(Tree $model)
{
if ($model->isRoot()) {
event(new CategoryRootCreated($model));
}
}
}
Register in AppServiceProvider:
Category::observe(CategoryObserver::class);
closure table, drop and re-run migrations to avoid schema mismatches.tree_type values; collisions will break queries.descendants on very deep trees (e.g., >100 levels). Use pagination:
$descendants = $node->descendants()->paginate(50);
closure table on (ancestor, descendant) for faster joins.$node->detach(); // Safe: no active queries
$node->moveTo($node); // Throws exception (prevents self-referential loops)
save(). Use:
$node->saveWithDepth(); // Explicitly recalculates depth
Category::recalculateDepths();
Enable query logging to inspect generated SQL:
DB::enableQueryLog();
$node->descendants; // Trigger query
dd(DB::getQueryLog());
tree() defines the correct pivot table.depth column exists in the pivot table.ancestor/descendant columns.Use BonsaiTestCase for assertions:
use Baril\Bonsai\Testing\BonsaiTestCase;
class CategoryTest extends BonsaiTestCase
{
public function testTreeStructure()
{
$root = Category::create(['name' => 'Root']);
$child = $root->children()->create(['name' => 'Child']);
$this->assertCount(1, $root->children);
$this->assertEquals(1, $child->depth);
}
}
Override the default closure table name:
protected $closureTable = 'custom_closure_table';
Extend Baril\Bonsai\Tree to modify depth logic:
protected function calculateDepth()
{
// Custom logic (e.g., skip certain nodes)
return parent::calculateDepth() + 1;
}
Enable soft deletes for trees:
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Tree
{
use SoftDeletes;
protected $dates = ['deleted_at'];
}
Note: Soft-deleted nodes are not included in descendants/ancestors by default. Use:
$node->withTrashed()->descendants;
Add extra pivot columns to the closure table:
public function tree()
{
return $this->belongsToMany(
static::class,
'categories_closure',
'ancestor',
'descendant',
'id',
'id'
)->withPivot(['depth', 'custom_metadata']);
}
Access via:
$node->children->first()->pivot->custom_metadata;
For multi-database setups, specify the connection:
public function tree()
{
return $this->belongsToMany(
static::class,
'categories_closure',
'ancestor',
'descendant',
'id',
'id'
)->setConnection('secondary');
}
How can I help you explore Laravel packages today?