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

Bonsai Laravel Package

baril/bonsai

Closure Table pattern implementation for Laravel Eloquent that lets you store and query tree-like structures in a relational database. Provides fast ancestor and descendant lookups across all supported DBMSs, migration artisan commands, and an alternative to nested sets.

View on GitHub
Deep Wiki
Context7

Getting Started

Bonsai stores tree structure in a separate closure table while keeping the canonical parent_id on your main table. That means you get O(1)-ish ancestor/descendant queries without the write complexity of nested sets.

Minimum setup:

  1. Add a parent_id column (nullable, self-referencing FK) to your table.
  2. Add the trait to your model:
use Baril\Bonsai\Concerns\BelongsToTree;

class Category extends Model
{
    use BelongsToTree;
}
  1. Generate and run the closure table migration:
php artisan bonsai:grow "App\Models\Category"
php artisan migrate
  1. If the table already has rows with parent_id populated, backfill the closures:
php artisan bonsai:fix "App\Models\Category"
  1. Verify the tree renders correctly before writing any application code:
php artisan bonsai:show "App\Models\Category" --label=name --depth=3

Where to look first: the README's "Relationships", "Methods", and "Query scopes" sections are the actual day-to-day API surface — read those before the full API docs at michaelbaril.github.io/bonsai/api/. Check the CHANGELOG for deprecations if you're copying older tutorials.

First realistic use case: a category tree for a CMS or storefront. You already have parent_id and recursive PHP loops; swap those for descendantsOf() and with('descendants') and you get breadcrumbs, subtree filtering, and "show all products in this category and its children" almost for free.

Implementation Patterns

Customizing columns/tables. Override the properties rather than fighting defaults:

class Tag extends Model
{
    use BelongsToTree;

    protected $parentForeignKey = 'parent_tag_id';
    protected $closureTable = 'tag_closures';
}

Do this at the start — changing the closure table name later means a migration and a full bonsai:fix.

Tree building UIs (drag-and-drop, nested menus). Eager-load descendants once and traverse children recursively with zero extra queries:

$categories = Category::with('descendants')->get();

foreach ($categories as $root) {
    renderNode($root); // $root->children already loaded, recursively
}

This is the single biggest pattern win — it replaces N+1 recursive queries in menu rendering.

Breadcrumbs and "where am I" logic. Use ancestors with depth ordering, or the scoped query:

$breadcrumbs = $category->ancestors()->orderByDepth()->withSelf()->get();

$path = Category::ancestorsOf($category, withSelf: true)
    ->orderByDepth()
    ->get();

getDepth() is useful for indentation; withDepth() attaches a depth column to results so you can sort/indent in one query.

Moving nodes. Prefer the semantic methods over manual parent_id writes — they're clearer in domain code:

$newParent->graft($child);   // $newParent->graft($node)
$child->graftOnto($newParent);
$node->cut();                // promote to root, descendants preserved

Under the hood these are associate() + save(), so model events still fire and the closure table stays consistent.

Filtering screens by subtree. Rather than loading everything and filtering in PHP:

$items = Product::whereIn(
    'category_id',
    Category::descendantsOf($category)->pluck('id')
)->paginate();

// or as a scope chain on the tree model itself:
$subtree = Category::descendantsOf($category, maxDepth: 2, withSelf: true)->get();

Deleting. Be explicit about intent, because delete() throws if the node has children:

$category->deleteTree();          // node + all descendants
$category->forceDeleteTree();     // with SoftDeletes trait

Wrap user-triggered deletes in a try/catch (TreeException $e) and prompt "delete children too?" — that's a natural UX flow this package pushes you toward.

Soft deletes. Swap the Illuminate trait for Bonsai's:

use Baril\Bonsai\Concerns\SoftDeletes;

Then restore carefully — a child whose parent was deleted mid-trash will throw:

try {
    $category->restore();          // or restoreTree() for the whole subtree
} catch (\Baril\Bonsai\TreeException $e) {
    $category->cut()->restore();   // re-attach as root
}

Ordered trees. If sibling order matters, install baril/orderly, add a position/order column, and pick Orderable (call ->ordered() manually) vs Ordered (children always ordered). Use Orderable if you have hot paths where ordering is a wasted sort.

Bulk imports and seeding. Import rows, then run bonsai:fix once at the end rather than saving thousands of models individually — save() on a node with a large subtree rewrites many closure rows. Alternatively insert parents before children so each save is cheap, and wrap in a transaction.

Gotchas and Tips

The closure table is derived data — never write to it. If it ever looks wrong (bad mass updates, manual SQL on parent_id, failed mid-transaction), bonsai:fix truncates and rebuilds it from parent_id. Keep that command in your runbook.

Bypassing Eloquent breaks the tree. DB::table('categories')->update(['parent_id' => ...]), raw seeds, or insert() all skip the closure maintenance. Always go through save(), graft(), or cut(), then re-run bonsai:fix.

Every save of a moved subtree rewrites closures. Reparenting a node with thousands of descendants is a heavy write. For big reorganizations, do it in a transaction, off-peak, or batch it and rebuild with bonsai:fix.

ancestors and descendants are read-only. attach()/detach() throw. Change structure via parent_id on the model, not through these relations — a common mistake when people treat them like normal BelongsToMany.

delete() throws on non-leaf nodes by design. If you catch and swallow TreeException without calling deleteTree(), the node survives; if you always call deleteTree(), you lose the safety net. Decide per endpoint.

Restoring soft-deleted children fails if the parent is gone. Always wrap restore() in try/catch in admin restore flows and offer the cut()->restore() fallback.

siblings excludes roots by default. Calling it on a root returns nothing. Add withOrphans() to include other roots, and withSelf() if the current node should appear in the list.

Depth filters don't lazily stop recursion. maxDepth() limits the query, but if you're traversing children from a descendants eager load, nodes at the boundary will lazy-load their children on access. Set explicit cutoffs in your rendering layer.

--migrate on bonsai:grow is deprecated. Run bonsai:grow then php artisan migrate separately.

Deprecated API to avoid in new code:

  • whereIsRoot() → onlyRoots(), whereIsLeaf() → onlyLeaves(), whereHasChildren() → hasChildren(), whereIsDescendantOf() → descendantsOf().
  • getTree() / getTreeDepth() are slated for replacement in v4 — don't build new features on them.
  • withAncestors() / withDescendants() → just use with('ancestors') / with('descendants').
  • BelongsToOrderedTree → BelongsToTree + Ordered.
  • deleteNode() → use deleteTree() or delete().

Database support is version-gated. MySQL-only before 3.3; SQLite/Postgres/SQL Server from 3.3+. Laravel 13 needs Bonsai 3.5+, Laravel 12 needs 3.3+, Laravel 10 needs 3.1+. Pin the package version to your Laravel version, not just ^3.

withHeight() requires Laravel 10+. It silently isn't available on older versions — check before relying on it for sorting.

Cycle protection is at the model layer. Setting parent_id to itself or a descendant throws TreeException on save, so validate at the form level too — otherwise admins get a 500 instead of a friendly error.

Debugging a suspicious tree: bonsai:show "App\Models\Category" --label=name gives you a quick ASCII view. Combine with withDepth() on a query to confirm the stored structure matches what you expect before touching application code.

Extension points: the $parentForeignKey and $closureTable properties, $orderColumn for ordered trees, the SoftDeletes/Orderable/Ordered concerns, and standard Eloquent model events around save/delete. If you need audit trails on structural changes, observe those events rather than wrapping the closure table.

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.
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin