jiaxincui/closure-table
Closure table implementation for Laravel Eloquent models, adding fast hierarchical tree queries and operations—ancestors, descendants, siblings, moving nodes—without recursive queries. Simple trait integration with support for multiple trees.
Install and pin.
composer require jiaxincui/closure-table:^3.0.8
Pin an exact minor (3.0.8), not a floating range. Release history shows return-shape and signature churn between 2.x and 3.x, plus bug fixes as recent as 3.0.5 for string IDs — you want a version you chose, not one Composer chose for you.
Create the closure table. This is a second table alongside your normal model table, holding one row per ancestor–descendant pair (including self-pairs at depth 0).
Schema::create('category_closure', function (Blueprint $table) {
$table->unsignedBigInteger('ancestor');
$table->unsignedBigInteger('descendant');
$table->unsignedInteger('depth')->default(0);
$table->primary(['ancestor', 'descendant']);
$table->index('descendant');
$table->index(['ancestor', 'depth']);
});
The composite primary key is your corruption guard — it makes duplicate path rows impossible. For UUID/string keys use string(36) columns instead of unsignedBigInteger (supported since 3.0.2, with SQL pre-check fixes through 3.0.5).
Wire the trait. Your model stays a normal Eloquent model; the trait only adds tree behaviour:
class Category extends Model
{
use \Jiaxincui\ClosureTable\Traits\ClosureTable;
protected $closureTable = 'category_closure';
protected $ancestorColumn = 'ancestor';
protected $descendantColumn = 'descendant';
protected $parentColumn = 'parent_id';
}
Where to look first. The README gets you the property names and the headline methods; then read vendor/jiaxincui/closure-table/src/Traits/ClosureTable.php end to end. It is small, and it is the only trustworthy documentation for edge cases — release notes are partly in Chinese and skip detail. Keep the file open in a split pane for your first week.
First use case — verify before you build. Don't start with the admin UI. Start in tinker with three nodes:
$a = Category::create(['name' => 'Root', 'parent_id' => null]);
$b = Category::create(['name' => 'Child', 'parent_id' => $a->id]);
$c = Category::create(['name' => 'Leaf', 'parent_id' => $b->id]);
DB::table('category_closure')->get(); // expect 6 rows: 3 self-pairs + 3 paths
$c->getAncestors(); // [Child, Root]
$a->getDescendants(); // [Child, Leaf]
If ancestor counts come back wrong here, stop — it's a schema/column-name mismatch, not a logic bug, and you'll save yourself a day of debugging a screen that "mysteriously" nests wrong.
Treat the closure table as a read accelerator, not a source of truth. Your parent_id column remains the canonical adjacency list. Closure rows are derived data. That framing dictates everything downstream: any repair script rebuilds closure rows from parent_id, and you never hand-edit closure rows.
Breadcrumbs — one indexed query, no recursion:
$trail = $category->getAncestors(); // ordered root → parent
This is the pattern that pays for the whole package. It replaces either a recursive CTE or a while ($node->parent) loop that becomes an N+1 the moment you render 20 breadcrumbs on a page.
Subtree listings. getDescendants() gives you the flat set; build the nested structure in PHP if you need it for a menu or a JSON resource. Do the ordering yourself (orderBy('name') on the returned collection) rather than relying on database ordering, which the release notes show has been a moving target (getRoot() ordering was fixed in 2.1.7).
Tree rendering. getTree() / toTree() returns a nested structure in one shot — useful for admin menus and pickers. Note the return shape: since 2.1.0 getTree() wraps its result in an extra array level to support multiple roots in one table. If you're upgrading from 2.0.x, every consumer breaks silently (it returns an array-of-trees, so foreach still works but produces the wrong nesting one level down). Write a test that asserts the exact shape, not just that it's non-empty — toTree() returned empty as recently as 3.0.4.
Writes go through the trait, never raw save. Creating a node with parent_id set lets the trait maintain closure rows. Same for addChild() and moveTo(). The moment someone writes $category->parent_id = $x; $category->save(); directly, you have a corrupt tree with no error raised — it just reads wrong later.
Wrap every mutation in a transaction, and lock the node being moved.
DB::transaction(function () use ($node, $newParent) {
Category::whereKey($node->id)->lockForUpdate()->first();
$node->moveTo($newParent);
});
Reparenting rewrites closure rows for the entire subtree. Two concurrent moves of overlapping subtrees can interleave and leave a path set that is internally inconsistent but superficially plausible. The package does not do this locking for you.
Hide it behind a service layer. Controllers and API resources should not call getDescendants() directly.
class CategoryTree
{
public function breadcrumbs(Category $c): Collection { /* ... */ }
public function subtreeIds(Category $c): array { /* ... */ }
}
This costs an hour and buys you: a single place to add caching, a single place to swap the implementation, and an exit hatch if the single maintainer disappears. Given 111 stars and zero dependents, assume you may end up owning a fork.
Multi-tree / multi-tenant. Several independent roots in one table is native and is often the deciding feature — a per-tenant taxonomy doesn't need a second table. The rule: every query must be scoped to its root. Pass the root explicitly into your service methods rather than deriving it, so a tenant boundary can never be inferred wrong.
Bulk operations belong in jobs. Category imports, reorgs from an ERP feed, and anything touching thousands of nodes generate nodes × depth closure writes inside one transaction. Queue them, chunk them, and tell the product owner that bulk moves are asynchronous. Single drag-and-drop reparent stays synchronous and feels instant.
Repair command, day one. Write php artisan tree:rebuild {model} before you need it. It truncates the closure table and regenerates from parent_id. It is your recovery path for a corrupt tree, your migration path off the package, and the thing that makes 2 a.m. survivable.
Write amplification is the real constraint. Reads are cheap; writes scale with subtree size. Deleting a node at the root of a 5,000-node subtree touches thousands of closure rows. If your product vision includes frequent drag-and-drop reorg of large trees, that's an architecture conversation, not a tuning exercise.
Circular references are not prevented. moveTo($descendant) — moving a node under its own child — is your validation to write. Check $newParent->getAncestors()->contains($node->id) (or the descendant set, whichever is cheaper for your data) before mutating. Add a test for it; it's the first thing a QA engineer will try.
String/UUID keys need ≥ 3.0.2 and realistically ≥ 3.0.5. The 3.0.5 note explicitly fixes character IDs causing SQL errors. If you're on UUIDs, bump to the latest 3.0.x and re-run your tree tests after any upgrade.
Version-specific traps, in order of how quietly they bite:
getTree() shape changed in 2.1.0 (multi-tree wrapping) — silent nesting off-by-one.getTree($columns) fails to build a tree if $columns omits the parent column (fixed 2.1.3) — so always pass the parent column explicitly.getRoot() returns self for a root since 2.1.10 — code that assumed null needs auditing.toTree() returning empty (3.0.4) — assert on content, not just type.Soft deletes need attention. Deleting softly should leave the tree readable; restoring should reattach paths. The package added a restored event check in 2.0.6/2.1.1, but orphan rows from hard-deleted children are still on you. Add a scheduled or manual cleanup, or use cascadeOnDelete at the FK level so closure rows can't outlive their nodes.
Debugging a wrong tree. Don't read the model code. Query the closure table for the suspect node:
DB::table('category_closure')->where('descendant', $id)->orderBy('depth')->get();
Depth must increment by exactly 1 along the chain, and the self-pair at depth 0 must exist. Missing self-pair = the node was inserted outside the trait. Gaps in depth = a partially failed mutation. Both are fixed by the rebuild command, not by surgery.
Indexes before launch, not after. Composite index on (ancestor, descendant) and (ancestor, depth) plus a separate index on descendant (ancestor lookups and descendant lookups use different leading columns). Without the descendant index, breadcrumbs table-scan.
Caching is yours to add. There's no first-class cache layer. The good news: the closure pattern makes cache invalidation tractable — key on the node id and flush its subtree on mutation. Don't reach for a cache tier until you've confirmed the indexed join is actually the bottleneck; usually it isn't.
Eager loading doesn't cross the trait. You can't with('descendants') and expect paths to hydrate. Instead, query the subtree flat once, key the collection by id, and assemble in memory. That's one query for a whole menu instead of one per node.
Extension points. Because it's a trait, overriding a method in your model is a supported-enough escape hatch for a small behaviour tweak (custom ordering, extra filtering). Do it in your own model, never in vendor/, and note it — a fork is a one-day job given the codebase size, and that's precisely why it's an acceptable dependency.
Test the edges, not the happy path. Your integration suite should cover: root with no parent, leaf with no children, move to a sibling, move a subtree two levels up, delete a middle node, restore from soft delete, and a rejected circular move. Those eight cases are the entire risk surface of this package in your app.
How can I help you explore Laravel packages today?