staudenmeir/laravel-adjacency-list
Laravel Eloquent extension that adds recursive relationships for tree and graph structures using common table expressions (CTEs). Traverse categories, page hierarchies, nested comments, and more across MySQL, PostgreSQL, SQLite, SQL Server, and MariaDB.
composer require staudenmeir/laravel-adjacency-list:"^1.0"
Check the version table in the README first — the constraint is tied to your Laravel version (Laravel 13 → 1.26, 12 → 1.24, 11 → 1.21, 10 → 1.13). On PowerShell, quote with "^^^^1.0".
The package adds recursive Eloquent relationships via CTEs. It works on top of an adjacency list you already have: a nullable parent key column. Confirm your database supports CTEs (MySQL 8+, MariaDB 10.2+, PostgreSQL 9.4+, SQLite 3.8.3+, SQL Server 2008+; SingleStore only for trees).
Where to look first, in this order:
HasRecursiveRelationships trait and its default key names (parent_id, primary key).tree() / treeOf() and toTree() — the "give me a whole hierarchy" path.First use case: a category tree or threaded comments table.
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable();
$table->string('name');
$table->string('slug');
$table->boolean('active')->default(true);
});
class Category extends Model
{
use \Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
}
Then, immediately:
$tree = Category::tree()->get(); // all roots + everything below
$nested = Category::tree()->get()->toTree(); // children[] nesting for JSON
$sub = Category::find(1)->descendants; // flat recursive children
$up = Category::find(5)->ancestors; // breadcrumb
Most day-to-day needs are covered by ancestors, descendants, siblings, rootAncestor, and the tree() scope. Reach for custom hasManyOfDescendants relationships only when you need to aggregate a related model across a subtree.
Eager loading and aggregates. The recursive relationships behave like normal Eloquent relations, so with, withCount, whereHas, and has all work:
$categories = Category::with('descendants')->get();
$categories = Category::withCount('descendants')->get();
$categories = Category::whereHas('siblings', fn ($q) => $q->where('name', 'John'))->get();
Breadcrumbs. ancestorsAndSelf() plus depthFirst() gives you a deterministic root-to-leaf order for rendering:
$crumbs = Category::find($id)->ancestorsAndSelf()->depthFirst()->get();
Rendering a menu / tree view. Fetch once, nest in PHP:
$tree = Category::tree()->breadthFirst()->get()->toTree();
For large trees, combine tree($maxDepth), loadTreeRelationships() (pre-links parent/ancestors on the loaded collection to cut N+1s), then toTree().
Ordering matters. breadthFirst() and depthFirst() are scopes on the recursive query; apply them before get(), and avoid interleaving your own orderBy on the same query if you care about the tree order — do secondary sorting in PHP or on the nested collection.
Filtering by position. Use the built-in scopes rather than hand-rolled whereNull/whereDoesntHave:
Category::isRoot()->get();
Category::isLeaf()->get(); // same as doesntHaveChildren()
Category::hasChildren()->get();
Depth-limited traversal. whereDepth() filters results but still builds the full subtree internally. When you only need three levels, use withMaxDepth(), which limits the CTE itself:
$descendants = Category::withMaxDepth(3, fn () => Category::find($id)->descendants);
$ancestors = Category::withMaxDepth(-3, fn () => Category::find($id)->ancestors);
Multi-tenant / scoped trees. treeOf() is the clean way to keep separate hierarchies apart, and withQueryConstraint() lets you prune the recursion (e.g. skip inactive nodes and their descendants). Note the constraint needs table-qualified columns:
$tree = Category::withQueryConstraint(function ($query) {
$query->where('categories.active', true);
}, fn () => Category::tree()->get());
Use withInitialQueryConstraint() / withRecursiveQueryConstraint() when you need different rules at the root vs. deeper levels.
Bulk operations on a subtree. The relations return builders, so writes work directly:
Category::find($id)->descendants()->update(['active' => false]);
Category::find($id)->siblings()->delete();
Relationship checks in domain logic. isChildOf(), isParentOf(), and getDepthRelatedTo() are handy in policies/authorization instead of writing your own path-walking:
if ($comment->isChildOf($parentComment)) { /* ... */ }
Related models across a subtree. Define hasManyOfDescendants() / hasManyOfDescendantsAndSelf() (and the BelongsToMany / MorphToMany / MorphedByMany variants) when you want, e.g., every post authored anywhere under a category:
public function recursivePosts()
{
return $this->hasManyOfDescendantsAndSelf(Post::class);
}
$count = Category::withCount('recursivePosts')->get();
Path-based URLs. Use getCustomPaths() to build a slug path (user-1/user-2/user-3) directly in SQL rather than looping in PHP; add 'reverse' => true for a leaf-first variant.
depth and path are injected columns. If your table already has either, override getDepthName() / getPathName() or you'll get collisions and confusing results. Same for a non-standard parent key (getParentKeyName()) or a non-id local key (getLocalKeyName()).select() narrow the query on recursive relationships unless you include the parent/local keys — the CTE needs them to join.whereDepth() is not a performance optimization. It filters after the fact (the README says so explicitly). If a deep tree is slow, switch to withMaxDepth().enableCycleDetection(): bool returning true. Add includeCycleStart(): bool when you want the is_cycle flag to find where the loop begins — useful for a one-off data repair command.toTree() needs the full set in memory. It nests by matching parent keys within the collection. If you paginate, filter rows out in PHP, or use whereDepth(), orphaned nodes silently fall off the tree. Fetch the complete hierarchy, then nest/serialize.loadTreeRelationships() is the fix for $node->parent N+1s when rendering a tree; it only links relationships already present in the loaded collection, so it won't magically load ancestors you didn't query.update on descendants() bypasses model events. No updated/saved hooks fire, so search indexing, cache invalidation, or audit logging won't run. Loop over models if you need observers, or dispatch an explicit job afterwards.descendants on a big collection multiplies memory. Category::with('descendants')->get() loads the whole recursive set per parent. Combine with withMaxDepth() or query tree() once and group in PHP.breadthFirst() / depthFirst() are orderings, not filters. They're pointless on plain Category::all(); they only shape recursive results. And a later orderBy() on the same builder can override them.withQueryConstraint closures. Unqualified columns in a CTE with self-joins will be ambiguous — the README examples qualify (users.active) for exactly this reason.SoftDeletes model still recurses through trashed rows unless you constrain the initial/recursive queries. Be explicit with withQueryConstraint() when deleted nodes should prune the subtree.getParentKeyName(), getLocalKeyName(), getDepthName(), getPathName(), getPathSeparator(), getCustomPaths(), enableCycleDetection(), includeCycleStart(). Overriding getPathSeparator() (e.g. to /) plus a custom slug path is the standard trick for SEO-friendly category URLs.->toSql() on a recursive relation and inspect the WITH RECURSIVE block. If results look wrong, check whether your constraint landed in the initial vs. recursive part — that's the usual culprit.How can I help you explore Laravel packages today?