hsharghi/nestablecollection
NestableCollection is a Laravel package that extends collections for working with nested, hierarchical data. Build trees, traverse parents and children, and manage nested sets with a familiar collection API, keeping your models and arrays organized.
The package adds hierarchical (parent/child) behaviour to Eloquent models. The 1.1.20 release adds a fluent parentColumn() method so you can point the package at a non-default foreign key instead of the assumed parent_id.
composer require <vendor>/<package>
php artisan vendor:publish --tag=<package>-config
parent_id; if your schema already uses something else (pid, parent_uuid, mother_id), keep it and override it in code:
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->nullable()->index();
$table->string('name');
$table->timestamps();
});
class Category extends Model
{
use HasTree; // trait name per package docs
protected static function booted(): void
{
static::parentColumn('parent_id'); // new in 1.1.20
}
}
$roots = Category::query()->whereNull('parent_id')->with('children')->get();
If children comes back empty while rows clearly have parents, the column override is usually the culprit — see Gotchas.Look first at the package's trait (for available scopes/relations) and its config file (for column names, depth limits, and caching). Those two files answer most day-to-day questions.
Declare the parent column in the model, not the caller. parentColumn() is model-level configuration. Setting it in a controller or a random query builder chain is fragile — it will not apply to relations, scopes, or code paths you did not touch. Centralise it in booted() or a protected static $parentColumn property if the package supports both.
Legacy schema bridging. The main reason to reach for parentColumn() is adopting the package on an existing table. Map the package's expectations onto your reality:
static::parentColumn('pid'); // legacy column
static::orderColumn('sort_order'); // if the package exposes this too
Keep the mapping in a single base model or trait so every child model (categories, comments, pages) inherits it.
Tree-building for API responses. Fetch once, assemble in PHP, rather than recursing per node:
$flat = Category::query()->orderBy('name')->get();
$tree = $flat->toTree(); // or your own recursive collection helper
return CategoryResource::collection($tree);
This avoids N+1 queries and keeps response latency flat as depth grows.
Eager loading to a known depth. For breadcrumbs and menus you rarely need the whole tree:
Category::with(['parent.parent.parent', 'children.children'])->find($id);
Or, if the package provides descendants/ancestors relations, constrain them rather than materialising everything.
Moving and re-parenting. Wrap moves in a transaction and re-read the model afterwards; most implementations mutate sibling ordering or path columns as a side effect:
DB::transaction(function () use ($node, $newParent) {
$node->parent()->associate($newParent)->save();
});
$node->refresh();
Multi-tenancy. If trees are scoped per tenant, the parent column alone is not enough — constrain every query by tenant and validate on move that the new parent belongs to the same tenant:
abort_unless($newParent->tenant_id === $node->tenant_id, 422);
Without this, users can graft a node under another tenant's parent and the tree queries will happily follow it.
Consistent ordering. Trees are unordered by default. Pick an explicit order column or a deterministic orderBy for siblings, or your menu ordering will drift between requests.
parentColumn() must be set before the first query. Static configuration called lazily (e.g. inside a scope) will not affect relations already resolved or eager loads already planned in the same request.pid, verify parent(), children(), ancestors(), and any withCount calls; some packages derive relation keys from the configured column, others hard-code parent_id.$fillable and to your validation rules — otherwise associate() works but create(['pid' => ...]) silently drops it.PUT produces an infinite loop in any recursive traversal, and the failure surfaces as a timeout, not an exception.save(), delete(), or re-parenting must invalidate the cache key. Key per tenant plus per root node if you cache subtrees.$model->getParentColumnName() or equivalent) in a failing test, and toSql() a suspicious scope. Nine times out of ten, a "tree is empty" bug is a column mismatch or a missing index, not a logic error.where parent_column = ?; without an index this degrades linearly on large tables.children, ancestors, and re-parenting behave identically to the parent_id default. This catches regressions when the package changes its column resolution again.uses the package trait and overrides the accessor for the parent column, rather than patching vendor code — the next release will overwrite it.How can I help you explore Laravel packages today?