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

Laravel Adjacency List Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The HasRecursiveRelationships trait and its default key names (parent_id, primary key).
  2. The included relationships list — that's your API surface.
  3. 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.

Implementation Patterns

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.

Gotchas and Tips

  • Your DB must support CTEs. This is the single biggest deployment risk — a SQLite dev environment on an old build or an older MySQL will fail at query time, not install time. Pin/verify in CI.
  • 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()).
  • Don't 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().
  • SQL Server has a default recursion cap (MAXRECURSION 100). Deep hierarchies will error there; test deep trees if SQL Server is in your matrix. Also remember SingleStore only supports trees, not graphs.
  • Cycles cause infinite loops. If bad data can produce a cycle, implement 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.
  • Bulk 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.
  • Eager-loading 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.
  • Table-qualify columns inside withQueryConstraint closures. Unqualified columns in a CTE with self-joins will be ambiguous — the README examples qualify (users.active) for exactly this reason.
  • Global scopes and soft deletes interact with the CTE. A 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.
  • Extension points worth knowing: 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.
  • Debugging tip: dump the generated SQL with ->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.
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.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource