yuriitatur/nested-data

Nested data storage-agnostic library

Maintainers

Package info

bitbucket.org/yurii_tatur/nested-data

pkg:composer/yuriitatur/nested-data

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

dev-master 2026-04-26 14:11 UTC

This package is auto-updated.

Last update: 2026-04-26 14:11:45 UTC


README

Quality Gate Status Coverage

Nested Data

A storage-agnostic PHP 8.4+ library for managing hierarchical data (categories, menus, organizational trees) using a materialized path model. Provides a repository decorator that wraps any RepositoryInterface-compatible repository and adds hierarchy querying capabilities.

Installation

composer require yuriitatur/nested-data

Setup

1. Prepare your entity

Implement NestedEntity and use NestedEntityTrait in your domain entity:

use YuriiTatur\Nested\Entities\NestedEntity;
use YuriiTatur\Nested\Entities\NestedEntityTrait;

class Category implements NestedEntity
{
    use NestedEntityTrait;

    public function __construct(
        private readonly int $id,
        public string $name,
        public string $slug = '',
    ) {}

    public function getId(): int
    {
        return $this->id;
    }
}

2. Implement an ancestry data hydrator

AncestryDataHydratorInterface controls which extra columns are stored alongside each path record (e.g. slug, sort order) and how they are mapped back onto your entity after a query:

use YuriiTatur\Nested\Hydration\AncestryDataHydratorInterface;
use YuriiTatur\Nested\Entities\NestedEntity;

class CategoryHydrator implements AncestryDataHydratorInterface
{
    public function enrich(NestedEntity $entity, array $ancestryData): void
    {
        $entity->slug = $ancestryData['slug'] ?? $entity->slug;
    }

    public function extract(NestedEntity $entity): array
    {
        return ['slug' => $entity->slug];
    }
}

If you have no extra per-position data, return [] from extract and do nothing in enrich.

3. Create the database table (Laravel)

Add a dedicated hierarchy table in a migration:

use YuriiTatur\Nested\Laravel\Database\NestedTable;

Schema::create('nested', function (Blueprint $table) {
    $table->id();
    NestedTable::register($table); // adds entity_id, path, depth columns
});

The entity_id column name can be customised:

NestedTable::register($table, 'category_id');

Add any extra columns your hydrator needs in the same migration.

4. Wrap your repository

use YuriiTatur\Nested\Repositories\HierarchyRepository;
use YuriiTatur\Nested\Laravel\Drivers\EloquentHierarchyDriver;

$driver = new EloquentHierarchyDriver(
    table: 'nested',
    primaryKey: 'id',
    relatedEntityId: 'entity_id',
    connection: null,   // optional — pass a named connection for multi-tenant setups
);

$repo = new HierarchyRepository(
    innerRepository: $categoryRepository,   // your existing RepositoryInterface
    hierarchyDatabaseDriver: $driver,
    transactions: $transactionRunner,
    ancestryDataHydrator: new CategoryHydrator,
    events: $eventDispatcher,
);

Non-Laravel / testing

Use InMemoryPhpHierarchyDriver when you don't have a database or want fast in-memory tests:

use YuriiTatur\Nested\Drivers\InMemoryPhpHierarchyDriver;
use YuriiTatur\Repository\Drivers\Memory\InMemoryPhpDriver;

$driver = new InMemoryPhpHierarchyDriver(new InMemoryPhpDriver);

Managing hierarchy

Adding a node to the tree

use YuriiTatur\Nested\ValueObjects\ParentPath;

// Place $category as a root node
$repo->addParentPath($category, new ParentPath());

// Place $category as a child of node with ID 1
$repo->addParentPath($category, new ParentPath(1));

// Place $category as a grandchild: parent chain 1 → 3
$repo->addParentPath($category, new ParentPath(1, 3));

addParentPath validates that:

  • The path does not create a circular dependency (entity ID must not appear in the path).
  • The parent path already exists in the hierarchy (root is always valid).

Removing a node from a position

$repo->deleteParentPath($category, new ParentPath(1, 3));

The entity's PathList must already contain the path being removed, so fetch the entity via get() (which enriches paths) rather than getById() before calling this.

Deleting an entity entirely

Deleting an entity automatically removes all its ancestry records in a single transaction:

$repo->delete($category);

Querying

All query methods return a new immutable repository instance and can be chained with regular RepositoryInterface methods (where, orderBy, limit, etc.).

Roots and depth

// All root nodes (depth = 0)
$roots = $repo->onlyRoots()->get();

// All nodes at depth 2
$depth2 = $repo->atDepthOf(2)->get();

Traversing the tree

// All ancestors of $category (every node in its path)
$ancestors = $repo->allAncestorsOf($category)->get();

// Only the direct parent
$parent = $repo->directAncestorOf($category)->getOne();

// All descendants at any depth
$subtree = $repo->allDescendantsOf($category)->get();

// Only direct children
$children = $repo->directDescendantsOf($category)->get();

// Siblings (same parent, different node)
$siblings = $repo->siblingsOf($category)->get();

Filtering by path positions

use YuriiTatur\Nested\ValueObjects\PathList;

// Entities that occupy any of the given positions
$results = $repo->forPaths(new PathList(new ParentPath(), new ParentPath(1)))->get();

Eager-loading descendants

By default descendants are lazy-loaded per entity when $entity->getDescendants() is first accessed. Use withDescendants() to load them all in a single pass:

$categories = $repo->withDescendants()->onlyRoots()->get();

foreach ($categories as $category) {
    // already loaded — no extra query
    foreach ($category->getDescendants() as $child) { ... }
}

Getting entity IDs including the subtree

// Returns a Collection of IDs: the entity itself + all descendants
$ids = $category->getSubtreeIds();

Caching

Wrap HierarchyRepository with CachedHierarchyRepository to add transparent Symfony-cache-based caching. The cache key covers both the entity query state and the hierarchy query state, so each unique combination of filters is cached independently.

use YuriiTatur\Nested\Repositories\CachedHierarchyRepository;

$cachedRepo = new CachedHierarchyRepository(
    inner: $repo,                 // any HierarchyRepositoryInterface
    cache: $tagAwareCache,        // Symfony\Contracts\Cache\TagAwareCacheInterface
    cachePrefix: 'categories',
    ttl: 3600,
);

addParentPath and deleteParentPath automatically invalidate the affected entity's cache tags. Use cleanCache() to flush all entries for this prefix.

Path value objects

ParentPath

Represents a single position in the hierarchy as an ordered list of ancestor IDs.

$path = new ParentPath(1, 3, 5); // represents /1/3/5/
(string) $path;                  // "/1/3/5/"
$path->depth;                    // 3
$path->getDirectParent();        // 5
$path->getParentsParents();      // [1, 3]
$path->hasParent(3);             // true

// Reconstruct from a stored string
$path = ParentPath::fromStringPath('/1/3/5/');

PathList

A mutable collection of ParentPath objects held on an entity. An entity can exist at multiple positions in the tree.

$list = $category->getPathList();
$list->hasPath(new ParentPath(1, 3)); // bool
count($list);                         // number of positions

Testing

composer test

License

This code is under MIT license, read more in the LICENSE file.