The package provides you with a simple solution that allows you to effortlessly create hierarchical structures for your Eloquent models. It leverages the materialized path pattern to represent the hierarchy of your data. It can be used for a wide range of use cases such as managing categories, nested comments, and more.
Install the package via Composer:
composer require nevadskiy/laravel-tree
When working with hierarchical data structures in your application, storing the structure using a self-referencing parent_id
column is a common approach.
While it works well for many use cases, it can become challenging when you need to make complex queries, such as finding all descendants of a given node.
One of the simples and effective solutions is the materialized path pattern.
The "materialized pattern" involves storing the full path of each node in the hierarchy in a separate path
column as a string.
The ancestors of each node are represented by a series of IDs separated by a delimiter.
For example, the categories database table might look like this:
id | name | parent_id | path |
---|---|---|---|
1 | Science | null | 1 |
2 | Physics | 1 | 1.2 |
3 | Mechanics | 2 | 1.2.3 |
4 | Thermodynamics | 2 | 1.2.4 |
With this structure, you can easily retrieve all descendants of a node using a SQL query:
SELECT * FROM categories WHERE path LIKE '1.%'
Using the PostgreSQL ltree extension we can go even further. This extension provides an additional ltree
column type designed specifically for this purpose.
In combination with a GiST index it allows executing lightweight and performant queries across an entire tree.
Now the SQL query will look like this:
SELECT * FROM categories WHERE path ~ '1.*'
All you have to do is to add a AsTree
trait to the model and add a path
column alongside the self-referencing parent_id
column to the model's table.
Let's get started by configuring a Category
model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Nevadskiy\Tree\AsTree;
class Category extends Model
{
use AsTree;
}
Next, create a migration for the model. The definition of the path
column depends on your database connection.
To add a path
column with the ltree
type and a GiST index, use the following code:
$table->ltree('path')->nullable()->spatialIndex();
The complete migration file may look like this:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->ltree('path')->nullable()->spatialIndex();
$table->timestamps();
});
Schema::table('categories', function (Blueprint $table) {
$table->foreignId('parent_id')
->nullable()
->index()
->constrained('categories')
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};
Sometimes the Ltree extension may be disabled in PostgreSQL. To enable it, you can publish and run a package migration:
php artisan vendor:publish --tag=pgsql-ltree-migration
To add a string path
column with and an index, use the following code:
$table->string('path')->nullable()->index();
Once you have configured your model, the package automatically handles all manipulations with the path
attribute based on the parent, so you do not need to set it manually.
To insert a root node, simply save the model to the database:
$root = new Category();
$root->name = 'Science';
$root->save();
To insert a child model, you only need to assign the parent_id
attribute or use the parent
or children
relation:
$child = new Category;
$child->name = 'Physics';
$child->parent()->associate($root);
$child->save();
As you can see, it works just as regular Eloquent models.
The AsTree
trait provides the following relations:
parent
children
ancestors
(read-only)descendants
(read-only)
The parent
and children
relations use default Laravel BelongsTo
and HasMany
relation classes.
The ancestors
and descendants
can be used only in the "read" mode, which means methods like make
or create
are not available.
So to save related nodes you need to use the parent
or children
relation.
The parent
relation uses the default Eloquent BelongsTo
relation class that needs the parent_id
column as a foreign key.
It allows getting a parent of the node.
echo $category->parent->name;
The children
relation uses a default Eloquent HasMany
relation class and is a reverse relation to the parent
.
It allows getting all children of the node.
foreach ($category->children as $child) {
echo $child->name;
}
The ancestors
relation is a custom relation that works only in "read" mode.
It allows getting all ancestors of the node (without the current node).
Using the attribute:
foreach ($category->ancestors as $ancestor) {
echo $ancestor->name;
}
Using the query builder:
$ancestors = $category->ancestors()->get();
Getting a collection with the current node and its ancestors:
$hierarchy = $category->joinAncestors();
The descendants
relation is a custom relation that works only in "read" mode.
It allows getting all descendants of the node (without the current node).
Using the attribute:
foreach ($category->descendants as $descendant) {
echo $descendant->name;
}
Using the query builder:
$ancestors = $category->descendants()->get();
Getting root nodes:
$roots = Category::query()->root()->get();
Getting nodes by the depth level:
$categories = Category::query()->whereDepth(3)->get();
Getting ancestors of the node (including the current node):
$ancestors = Category::query()->whereSelfOrAncestorOf($category)->get();
Getting descendants of the node (including the current node):
$descendants = Category::query()->whereSelfOrDescendantOf($category)->get();
Ordering nodes by depth:
$categories = Category::query()->orderByDepth()->get();
$categories = Category::query()->orderByDepthDesc()->get();
The package provides the HasManyDeep
relation that can be used to link, for example, a Category
model that uses the AsTree
trait with a Product
model.
That allows us to get products of a category and each of its descendants.
Here is the code example on how to use the HasManyDeep
relation:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Nevadskiy\Tree\AsTree;
use Nevadskiy\Tree\Relations\HasManyDeep;
class Category extends Model
{
use AsTree;
public function products(): HasManyDeep
{
return HasManyDeep::between($this, Product::class);
}
}
Now you can get products:
$products = $category->products()->paginate(20);
You can easily get the products of a category and each of its descendants using a query builder.
1st way (recommended):
$products = Product::query()
->join('categories', function (JoinClause $join) {
$join->on('products.category_id', 'categories.id');
})
->whereSelfOrDescendantOf($category)
->paginate(24, ['products.*']);
2nd way (slower):
$products = Product::query()
->whereHas('category', function (Builder $query) use ($category) {
$query->whereSelfOrDescendantOf($category);
})
->paginate(24);
When you move a node, the path
column of the node and each of its descendants have to be updated as well.
Fortunately, the package does this automatically using a single query every time it sees that the parent_id
column has been updated.
So basically to move a node along with its subtree, you need to update the parent
node of the current node:
$science = Category::query()->where('name', 'Science')->firstOrFail();
$physics = Category::query()->where('name', 'Physics')->firstOrFail();
$physics->parent()->associate($science);
$physics->save();
To build a tree, we need to call the tree
method on the NodeCollection
:
$tree = Category::query()->orderBy('name')->get()->tree();
This method associates nodes using the children
relation and returns only root nodes.
echo $category->joinAncestors()->reverse()->implode('name', ' > ');
Delete the current node and all its descendants:
$category->newQuery()->whereSelfOrDescendantOf($category)->delete();
- https://www.postgresql.org/docs/current/ltree.html
- https://patshaughnessy.net/2017/12/13/saving-a-tree-in-postgres-using-ltree
- https://patshaughnessy.net/2017/12/14/manipulating-trees-using-sql-and-the-postgres-ltree-extension
Thank you for considering contributing. Please see CONTRIBUTING for more information.
The MIT License (MIT). Please see LICENSE for more information.