Я участвую в разработке ERP системы, в детали посвящать вас не буду, это тайна за семью печатями, но скажу лишь одно, древовидных справочников у нас много и вложенность их не ограничена, кол-во в некоторых составляет несколько тысяч, а работать с ними надо максимально эффективно и удобно, как с точки зрения кода, так с точки зрения производительности.

Что нам нужно

  • Быстро доставать любые срезы дерева и его ветвей, как вверх к родителям, так и вниз к листьям

  • Уметь также доставать все узлы, кроме листьев

  • Дерево должно быть консистентным, т.е. не иметь пропущенных узлов в иерархии родителей

  • Материализованные пути должны строиться автоматом

  • При перемещении или удалении узла, обновляются также все дочерние узлы и перестраиваются их пути

  • Научиться из плоской коллекции, быстро построить дерево.

  • Так как справочников много, компоненты должны быть переиспользуемые

  • Так как планируется выносить в гитхаб, задействовать абстракции и интерфейсы.

Так как мы используем Postgres, выбор пал на ltree, подробнее о том, что это такое можно прочитать в конце статьи.

Установка расширения

Пример миграции для создания расширения
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;

class CreateLtreeExtension extends Migration
{
    public function up(): void
    {
        DB::statement('CREATE EXTENSION IF NOT EXISTS LTREE');
    }

    public function down(): void
    {
        DB::statement('DROP EXTENSION IF EXISTS LTREE');
    }
}

Задача

Например, у нас есть товары и есть категории товаров. Категории представляют собой дерево и могут иметь подчиненные категории, уровень вложенности неограничен и может быть любой. Допустим это будут таблицы.

Категории товаров (categories)

id

bigserial

PK

path

ltree

материализованный путь

parent_id

biginteger

родительская категория

В path будут храниться материализованные пути, пример

id: 1
path: 1
parent_id: null

id: 2
path: 1.2
parent_id: 2

и тд..

Товары (products)

id

bigserial

PK

category_id

biginteger

категория

Если вы уже используете пакет для Postgres, значит вы уже знаете, что в добавляя новый extension, у нас появляется новый тип данных для Doctrine и его нужно зарегистрировать, сделать это не сложно, достаточно через composer установить этот пакет и тогда это бремя за вас сделает провайдер, который автоматом будет зарегистрирован:

composer require umbrellio/laravel-ltree
Пример миграции, использованием нового типа в Postgres
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Umbrellio\Postgres\Schema\Blueprint;

class CreateCategoryExtension extends Migration
{
    public function up(): void
    {
        Schema::table('categories', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->bigInteger('parent_id')->nullable();
            $table->ltree('path')->nullable();

            $table
                ->foreign('parent_id')
                ->references('id')
                ->on('categories');

            $table->index(['parent_id']);
            $table->unique(['path']);
        });

        DB::statement("COMMENT ON COLUMN categories.path IS '(DC2Type:ltree)'");
    }

    public function down(): void
    {
        Schema::drop('categories');
    }
}

При такой структуре самое простое, что приходит на ум, чтобы достать все дерево категорий товаров, это такой запрос:

SELECT * FROM categories;

Но, это нам вернет плоский список категорий, а отрисовать их, разумеется, нам надо в виде дерева. Поэтому самое простое решение, если бы мы не использовали ltree, был бы такой запрос:

SELECT * FROM categories WHERE parent_id IS NULL

Иными словами, только корневые категории. Ну а далее, используя рекурсию, отрисовывая корневые категории, будем дергать запрос для получения дочерних категорий, передавая туда ID категории:

SELECT * FROM categories WHERE parent_id = <ID>

Но, как вы видите, это долго, муторно и совсем не интересно, когда у нас в распоряжении есть ltree. С ним становится все гораздо проще, чтобы достать все дочерние категории, начиная от корня, ну или не от корня, а от произвольного узла, достаточно такого запроса:

SELECT * FROM categories WHERE path @> text2ltree('<ID>')

Вернемся к Laravel

Для начала напишем интерфейс древовидной модели и пару методов, для работы с ltree. Как вы их писать через абстрактный класс или через трейт не так важно, дело вкуса каждого, я выбираю трейты, т.к. если мне понадобится унаследовать модели не от Eloquent\Model я всегда смогу это сделать.

Пример интерфейса: LTreeInterface
<?php

namespace Umbrellio\LTree\Interfaces;

interface LTreeInterface
{
    public const AS_STRING = 1;
    public const AS_ARRAY = 2;
  
    public function getLtreeParentColumn(): string;
    public function getLtreeParentId(): ?int;
    public function getLtreePathColumn(): string;
    public function getLtreePath($mode = self::AS_ARRAY);
    public function getLtreeLevel(): int;
}

Пример трейта: LTreeTrait
<?php

trait LTreeTrait
{
    abstract public function getAttribute($key);
    
    public function getLtreeParentColumn(): string
    {
        return 'parent_id';
    }

    public function getLtreePathColumn(): string
    {
        return 'path';
    }

    public function getLtreeParentId(): ?int
    {
        $value = $this->getAttribute($this->getLtreeParentColumn());
        return $value ? (int) $value : null;
    }

    public function getLtreePath($mode = LTreeInterface::AS_ARRAY)
    {
        $path = $this->getAttribute($this->getLtreePathColumn());
        if ($mode === LTreeModelInterface::AS_ARRAY) {
            return $path !== null ? explode('.', $path) : [];
        }
        return (string) $path;
    }

    public function getLtreeLevel(): int
    {
        return is_array($path = $this->getLtreePath()) ? count($path) : 1;
    

Пример модели, реализующей интерфейс LTreeInterface: Category
<?php

final class Category extends Model implements LTreeInterface
{
    use LTreeTrait;

    protected $table = 'categories';
    protected $fillable = ['parent_id', 'path'];
    protected $timestamps = false;
}

Теперь, добавим несколько скоупов для удобной работы с материализованными путями:

Пример трейта: LTreeTrait
<?php

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Umbrellio\LTree\Collections\LTreeCollection;
use Umbrellio\LTree\Interfaces\LTreeModelInterface;

trait LTreeTrait
{
    //...
  
		public function scopeParentsOf(Builder $query, array $paths): Builder
    {
        return $query->whereRaw(sprintf(
            "%s @> array['%s']::ltree[]",
            $this->getLtreePathColumn(),
            implode("', '", $paths)
        ));
    }

    public function scopeRoot(Builder $query): Builder
    {
        return $query->whereRaw(sprintf('nlevel(%s) = 1', $this->getLtreePathColumn()));
    }

    public function scopeDescendantsOf(Builder $query, LTreeModelInterface $model): Builder
    {
        return $query->whereRaw(sprintf(
            "({$this->getLtreePathColumn()} <@ text2ltree('%s')) = true",
            $model->getLtreePath(LTreeModelInterface::AS_STRING),
        ));
    }

    public function scopeAncestorsOf(Builder $query, LTreeModelInterface $model): Builder
    {
        return $query->whereRaw(sprintf(
            "({$this->getLtreePathColumn()} @> text2ltree('%s')) = true",
            $model->getLtreePath(LTreeModelInterface::AS_STRING),
        ));
    }

    public function scopeWithoutSelf(Builder $query, int $id): Builder
    {
        return $query->whereRaw(sprintf('%s <> %s', $this->getKeyName(), $id));
    }

Где:

  • scopeAncestorsOf - позволяет доставать нам всех родителей вверх до корня (включая текущий узел)

  • scopeDescendantsOf - позволяет доставать всех детей вниз до листика (включая текущий узел)

  • scopeWithoutSelf - исключает текущий узел

  • scopeRoot - позволяет достать только корневые узлы 1-ого уровня

  • scopeParentsOf - почти тоже самое что и scopeAncestorsOf, только для нескольких узлов.

Т.е. добавив трейт к модели, она уже умеет при помощи нехитрых манипуляций работать с отдельными ветками, получать родителей, детей и тд.

Усложним задачу

Представим, что товары могут лежать не только в листиках, но и в промежуточных узлах. На входе мы имеем категорию - уровня листик, какого уровня вложенности мы не знаем, да это и не важно. Нужно достать все товары из категории, а также из всех родительских категорий этого листика.

Самое простое, что приходит на ум, нам нужно достать все идентификаторы категорий, а потом запросить товары находящиеся в выбранных категориях:

<?php

// ID категории (листика) = 15

$categories = Category::ancestorsOf(15)->get()->pluck('id')->toArray();
$products = Product::whereIn('category_id', $caregories)->get();

Рисуем дерево

Да, мы научились работать с древовидной структурой на уровне БД, но как же отрисовать дерево, все еще не понятно.

Задача все та же, это должен быть 1 запрос и нарисовать мы должны дерево, вернемся к запросу:

SELECT * FROM categories;

Для того, чтобы используя этот запрос, у нас было дерево, первое что приходит на ум, это нам надо каким-то образом преобразовать массив вида:

<?php

$a = [
  [1, '1', null],
  [2, '1.2', 1],
  [3, '1.2.3', 2],
  [4, '4', null],
  [5, '1.2.5', 2],
  [6, '4.6', 4],
  // ...
];

К такому:

<?php

$a = [
    0 => [
        'id' => 1,
        'level' => 1,
        'children' => [
            0 => [
                'id' => 2,
                'level' => 2,
                'children' => [
                    0 => [
                        'id' => 3,
                        'level' => 3,
                        'children' => [],
                    ],
                    1 => [
                        'id' => 5,
                        'level' => 3,
                        'children' => [],
                    ],
                ]
            ]
        ]
   ],
   1 => [
       'id' => 4,
       'level' => 1,
       'children' => [
            0 => [
                'id' => 6,
                'level' => 2,
                'children' => [],
            ]
       ]
   ]
];

Тогда при помощи обычной рекурсии мы смогли бы отрисовать дерево, пример грубый, написан на коленке, но это сейчас не важно:

<?php

$categories = Category::all()->toTree(); // Collection

function renderTree(Collection $collection) {
   /** @var LTreeNode $item */
   foreach ($collection as $item) {
      if ($item->children->isNotEmpty()) {
         renderTree($item->children);
         return;
      }
   }
   
   echo str_pad($item->id, $item->level - 1, "---", STR_PAD_LEFT) . PHP_EOL;
}

Немного изменений

Доработаем нашу модель, а именно трейт, добавив в него методы для гидрации в особую коллекцию:

<?php

trait LTreeTrait
{
    //...

    public function newCollection(array $models = []): LTreeCollection
    {
        return new LTreeCollection($models);
    }

    public function ltreeParent(): BelongsTo
    {
        return $this->belongsTo(static::class, $this->getLtreeParentColumn());
    }

    public function ltreeChildren(): HasMany
    {
        return $this->hasMany(static::class, $this->getLtreeParentColumn());
    }
}

Данный метод будет возвращать специальную коллекцию, получив на входе плоский массив, преобразует его к дереву, когда это будет необходимо.

Пример коллекции: LTreeCollection
<?php

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Umbrellio\LTree\Helpers\LTreeBuilder;
use Umbrellio\LTree\Helpers\LTreeNode;
use Umbrellio\LTree\Interfaces\HasLTreeRelations;
use Umbrellio\LTree\Interfaces\LTreeInterface;
use Umbrellio\LTree\Interfaces\ModelInterface;
use Umbrellio\LTree\Traits\LTreeModelTrait;

class LTreeCollection extends Collection
{
    private $withLeaves = true;

    public function toTree(bool $usingSort = true, bool $loadMissing = true): LTreeNode
    {
        if (!$model = $this->first()) {
            return new LTreeNode();
        }

        if ($loadMissing) {
            $this->loadMissingNodes($model);
        }

        if (!$this->withLeaves) {
            $this->excludeLeaves();
        }

        $builder = new LTreeBuilder(
            $model->getLtreePathColumn(),
            $model->getKeyName(),
            $model->getLtreeParentColumn()
        );

        return $builder->build($collection ?? $this, $usingSort);
    }

    public function withLeaves(bool $state = true): self
    {
        $this->withLeaves = $state;

        return $this;
    }

    private function loadMissingNodes($model): self
    {
        if ($this->hasMissingNodes($model)) {
            $this->appendAncestors($model);
        }

        return $this;
    }

    private function excludeLeaves(): void
    {
        foreach ($this->items as $key => $item) {
            if ($item->ltreeChildren->isEmpty()) {
                $this->forget($key);
            }
        }
    }

    private function hasMissingNodes($model): bool
    {
        $paths = collect();

        foreach ($this->items as $item) {
            $paths = $paths->merge($item->getLtreePath());
        }

        return $paths
            ->unique()
            ->diff($this->pluck($model->getKeyName()))
            ->isNotEmpty();
    }
  
    private function appendAncestors($model): void
    {
        $paths = $this
            ->pluck($model->getLtreePathColumn())
            ->toArray();
        $ids = $this
            ->pluck($model->getKeyName())
            ->toArray();

        $parents = $model::parentsOf($paths)
            ->whereKeyNot($ids)
            ->get();

        foreach ($parents as $item) {
            $this->add($item);
        }
    }
}

Эта коллекция по сути ничем не отличается от встроенной в Laravel, т.е. если ее итерировать мы все еще будем иметь плоский список. Но если вызвать метод toTree, то плоская коллекция, где все категории всех уровней вложенности были на одном уровне, рекурсивно выстроятся в свойства children и мы получим из обычного плоского массива - многоуровневый массив соответствующий нашему дереву.

А используя специальные скоупы, мы сможем строить деревья для отдельных ветвей, например:

<?php

$categories = Category::ancestorsOf(15)->get()->toTree();

Также нам понадобится еще два класса, которые будут собственно строить дерево, это класс - представляющий узел и класс билдера, который будет рекурсивно обходить коллекцию и выстраивать узлы в нужном нам порядке (и сортировке):

LTreeNode
<?php

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use Umbrellio\Common\Contracts\AbstractPresenter;
use Umbrellio\LTree\Collections\LTreeCollection;
use Umbrellio\LTree\Interfaces\LTreeInterface;
use Umbrellio\LTree\Interfaces\ModelInterface;

class LTreeNode extends AbstractPresenter
{
    protected $parent;
    protected $children;

    public function __construct($model = null)
    {
        parent::__construct($model);
    }

    public function isRoot(): bool
    {
        return $this->model === null;
    }

    public function getParent(): ?self
    {
        return $this->parent;
    }

    public function setParent(?self $parent): void
    {
        $this->parent = $parent;
    }

    public function addChild(self $node): void
    {
        $this
            ->getChildren()
            ->add($node);
        $node->setParent($this);
    }

    public function getChildren(): Collection
    {
        if (!$this->children) {
            $this->children = new Collection();
        }
        return $this->children;
    }

    public function countDescendants(): int
    {
        return $this
            ->getChildren()
            ->reduce(
                static function (int $count, self $node) {
                    return $count + $node->countDescendants();
                },
                $this
                    ->getChildren()
                    ->count()
            );
    }

    public function findInTree(int $id): ?self
    {
        if (!$this->isRoot() && $this->model->getKey() === $id) {
            return $this;
        }
        foreach ($this->getChildren() as $child) {
            $result = $child->findInTree($id);
            if ($result !== null) {
                return $result;
            }
        }
        return null;
    }

    public function each(callable $callback): void
    {
        if (!$this->isRoot()) {
            $callback($this);
        }
        $this
            ->getChildren()
            ->each(static function (self $node) use ($callback) {
                $node->each($callback);
            });
    }

    public function toCollection(): LTreeCollection
    {
        $collection = new LTreeCollection();
        $this->each(static function (self $item) use ($collection) {
            $collection->add($item->model);
        });
        return $collection;
    }

    public function pathAsString()
    {
        return $this->model ? $this->model->getLtreePath(LTreeInterface::AS_STRING) : null;
    }

    public function toTreeArray(callable $callback)
    {
        return $this->fillTreeArray($this->getChildren(), $callback);
    }

    /**
     * Usage sortTree(['name' =>'asc', 'category'=>'desc'])
     * or callback with arguments ($a, $b) and return -1 | 0 | 1
     * @param array|callable $options
     */
    public function sortTree($options)
    {
        $children = $this->getChildren();
        $callback = $options;
        if (!is_callable($options)) {
            $callback = $this->optionsToCallback($options);
        }
        $children->each(static function ($child) use ($callback) {
            $child->sortTree($callback);
        });
        $this->children = $children
            ->sort($callback)
            ->values();
    }

    private function fillTreeArray(iterable $nodes, callable $callback)
    {
        $data = [];
        foreach ($nodes as $node) {
            $item = $callback($node);
            $children = $this->fillTreeArray($node->getChildren(), $callback);
            $item['children'] = $children;
            $data[] = $item;
        }
        return $data;
    }

    private function optionsToCallback(array $options): callable
    {
        return function ($a, $b) use ($options) {
            foreach ($options as $property => $sort) {
                if (!in_array(strtolower($sort), ['asc', 'desc'], true)) {
                    throw new InvalidArgumentException("Order '${sort}'' must be asc or desc");
                }
                $order = strtolower($sort) === 'desc' ? -1 : 1;
                $result = $a->{$property} <=> $b->{$property};
                if ($result !== 0) {
                    return $result * $order;
                }
            }
            return 0;
        };
    }
}

LTreeBuilder
<?php

class LTreeBuilder
{
    private $pathField;
    private $idField;
    private $parentIdField;
    private $nodes = [];
    private $root = null;

    public function __construct(string $pathField, string $idField, string $parentIdField)
    {
        $this->pathField = $pathField;
        $this->idField = $idField;
        $this->parentIdField = $parentIdField;
    }

    public function build(LTreeCollection $items, bool $usingSort = true): LTreeNode
    {
        if ($usingSort === true) {
            $items = $items->sortBy($this->pathField, SORT_STRING);
        }

        $this->root = new LTreeNode();

        foreach ($items as $item) {
            $node = new LTreeNode($item);

            [$id, $parentId] = $this->getNodeIds($item);

            $parentNode = $this->getNode($parentId);
            $parentNode->addChild($node);

            $this->nodes[$id] = $node;
        }
        return $this->root;
    }

    private function getNodeIds($item): array
    {
        $parentId = $item->{$this->parentIdField};
        $id = $item->{$this->idField};

        if ($id === $parentId) {
            throw new LTreeReflectionException($id);
        }
        return [$id, $parentId];
    }

    private function getNode(?int $id): LTreeNode
    {
        if ($id === null) {
            return $this->root;
        }
        if (!isset($this->nodes[$id])) {
            throw new LTreeUndefinedNodeException($id);
        }
        return $this->nodes[$id];
    }
}

Для простоты отрисовки дерева напишем еще два абстрактных ресурса:

AbstractLTreeResource
<?php

namespace Umbrellio\LTree\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Collection;
use Umbrellio\LTree\Collections\LTreeCollection;

abstract class LTreeResourceCollection extends ResourceCollection
{
    /**
     * @param LTreeCollection|Collection $resource
     */
    public function __construct($resource, $sort = null, bool $usingSort = true, bool $loadMissing = true)
    {
        $collection = $resource->toTree($usingSort, $loadMissing);

        if ($sort) {
            $collection->sortTree($sort);
        }

        parent::__construct($collection->getChildren());
    }
}

AbstractLTreeResourceCollection
<?php

namespace Umbrellio\LTree\Resources;

use Illuminate\Http\Resources\Json\JsonResource;
use Umbrellio\LTree\Helpers\LTreeNode;
use Umbrellio\LTree\Interfaces\LTreeInterface;

/**
 * @property LTreeNode $resource
 */
abstract class LTreeResource extends JsonResource
{
    final public function toArray($request)
    {
        return array_merge($this->toTreeArray($request, $this->resource->model), [
            'children' => static::collection($this->resource->getChildren())->toArray($request),
        ]);
    }

    /**
     * @param LTreeInterface $model
     */
    abstract protected function toTreeArray($request, $model);
}

Вперед на амбразуру

Использовать примерно будем так, создадим два ресурса JsonResource:

CategoryResource
<?php

use Umbrellio\LTree\Helpers\LTreeNode;
use Umbrellio\LTree\Resources\LTreeResource;

class CategoryResource extends LTreeResource
{
    public function toTreeArray($request, LTreeNode $model)
    {
        return [
            'id' => $model->id,
            'level' => $model->getLtreeLevel(),
        ];
    }
}

CategoryResourceCollection
<?php

use Umbrellio\LTree\Resources\LTreeResourceCollection;

class CategoryResourceCollection extends LTreeResourceCollection
{
    public $collects = CategoryResource::class;
}

Представим, что у вас есть контроллер CategoryController и метод АПИ data возвращающий категории в формате json:

Пример контроллера
<?php

use Illuminate\Routing\Controller;
use Illuminate\Http\Request;

class CategoryController extends Controller
{
    //...
  
    public function data(Request $request)
    {
        return response()->json(
            new CategoryResourceCollection(
                Category::all(),
                ['id' => 'asc']
            )
        );
    } 
}

Используя специальные ресурсы, вам не нужно принудительно вызывать метод toTree, т.к. все методы Eloquent\Builder-а (get, all, first и тд) в модели реализующей интерфейс LtreeInterface возвращают LtreeCollection, то при использовании данных ресурсов, плоская коллекция автоматически преобразуется к дереву, причем без дополнительных запросов к БД.

Читать научились, научимся писать

Все описанное выше, помогло нам прочитать дерево из базы данных, отдельную ветвь или все дерево, а также мы научились отрисовывать дерево.

Но чтобы это все мы могли делать, мы должны сначала это дерево уметь сохранять, для этого необходимо сохранить в БД материализованные пути для каждого узла нашего будущего дерева.

Первое что приходит на ум, это форма с двумя полями:

id

input

parent_id

select

Т.е. мы создаем элемент, и если нам нужен корневой, то parent_id не заполняем, а если нужно то заполняем. А path по идее должен генерироваться автоматически на основании id и parent_id.

Для этого создадим сервис, который будем вызывать после сохранения нашей модели.

Пример сервиса генерирующего path
<?php

namespace Umbrellio\LTree\Services;

use Illuminate\Database\Eloquent\Model;
use Umbrellio\LTree\Helpers\LTreeHelper;
use Umbrellio\LTree\Interfaces\LTreeModelInterface;
use Umbrellio\LTree\Interfaces\LTreeServiceInterface;

final class LTreeService implements LTreeServiceInterface
{
    private $helper;

    public function __construct(LTreeHelper $helper)
    {
        $this->helper = $helper;
    }

    public function createPath(LTreeModelInterface $model): void
    {
        $this->helper->buildPath($model);
    }

    public function updatePath(LTreeModelInterface $model): void
    {
        $columns = array_intersect_key($model->getAttributes(), array_flip($model->getLtreeProxyUpdateColumns()));

        $this->helper->moveNode($model, $model->ltreeParent, $columns);
        $this->helper->buildPath($model);
    }

    public function dropDescendants(LTreeModelInterface $model): void
    {
        $columns = array_intersect_key($model->getAttributes(), array_flip($model->getLtreeProxyDeleteColumns()));

        $this->helper->dropDescendants($model, $columns);
    }
}

  • createPath - создает path для нового узла, нужно вызывать после создания

  • updatePath - обновляет path при редактировании. Тут важно понимать, что меняя path текущего узла, необходимо также обновить и пути всех его дочерних элементов, и желательно сделать это одним запросом, т.к. в случае если дочерних элементов будет 1000 - делать тысячу запросов на UPDATE как-то не комильфо.

    т.е. если мы перемещаем узел в другую подветвь, то вместе с ним перемещаются также и все его дети.

  • dropDescendants - метод удаляющий всех детей, при удалении узла, тут тоже важно понимать очередность, нельзя удалить узел, не удалив детей, вы сделаете дерево неконсистентным, а детей нужно удалять прежде, чем будете удалять узел.

Методы getLtreeProxyDeleteColumns и getLtreeProxyUpdateColumns - нужны для проксирования полей типа deleted_at, updated_at, editor_id и других полей, которые вы также хотите обновить в дочерних узлах при обновлении текущего узла или удалении.

<?php

class CategoryService
{
   private LTreeService $service;
  
   public function __construct (LTreeService $service)
   {
      $this->service = $service; 
   }
  
   public function create (array $data): void
   {
       $model = App::make(Category::class);
       $model->fill($data);
       $model->save();
     
       // создаем материализованный путь для узла
       $this->service->createPath($model);
   }
}

Конечно, можно запилить немного магии, добавить эвенты, листенеры и дергать методы автоматически, но я больше предпочитаю самому вызывать нужные методы, так хоть есть немного понимания, что происходит за кулисами + можно все это обернуть в транзакцию и не бояться, что где-то вылезет Deadlock.

Подведем итог

Если у вас Postgres / PHP / Laravel и у вас есть потребность в использовании древовидных справочников (категории, группы и тд), вложенность неограниченная, и вы хотите быстро и просто доставать любые срезы ветвей, вам точно будет полезен этот пакет.

Использовать его достаточно просто:

  1. Подключаете зависимость в composer: umbrellio/laravel-ltree

  2. Пишете миграцию с использованием типа ltree (добавляете parent_id и path в вашу таблицу-справочник)

  3. Имплементируете интерфейс LTreeModelInterface и подключаете трейт LTreeModelTrait в Eloquent\Model-и.

  4. Используете сервис LTreeService при операциях создания / обновления и удаления модели

  5. Используете ресурсы LTreeResource и LTreeResourceCollection, если у вас SPA

Ресурсы для изучения

  • https://postgrespro.ru/docs/postgresql/13/ltree - тут описания расширения Postgres, с примерами и на русском языке

  • https://www.postgresql.org/docs/13/ltree.html -для тех, кто любит читать мануалы в оригинале

Спасибо за внимание.