PHPackages                             happenv-com/laravel-ltree - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. happenv-com/laravel-ltree

ActiveLibrary[Database &amp; ORM](/categories/database)

happenv-com/laravel-ltree
=========================

The reference-quality Laravel integration for PostgreSQL ltree, lquery and ltxtquery.

v1.0.0(1mo ago)00[1 PRs](https://github.com/happenv-com/laravel-ltree/pulls)MITPHPPHP ^8.3CI passing

Since Jul 10Pushed 1mo agoCompare

[ Source](https://github.com/happenv-com/laravel-ltree)[ Packagist](https://packagist.org/packages/happenv-com/laravel-ltree)[ RSS](/packages/happenv-com-laravel-ltree/feed)WikiDiscussions 1.x Synced 1w ago

READMEChangelog (1)Dependencies (8)Versions (2)Used By (0)

Laravel LTree
=============

[](#laravel-ltree)

  ![Laravel LTree](https://camo.githubusercontent.com/53e5f1f0ad7802e79ca3e59db4b9863f634857afd10ddc15a03184ceab267e2a/68747470733a2f2f62616e6e6572732e6265796f6e64636f2e64652f4c61726176656c2532304c547265652e706e673f7468656d653d6c69676874267061636b6167654d616e616765723d636f6d706f7365722b72657175697265267061636b6167654e616d653d68617070656e762d636f6d2532466c61726176656c2d6c74726565267061747465726e3d746f706f677261706879267374796c653d7374796c655f31266465736372697074696f6e3d506f737467726553514c2b6c747265652532432b6c71756572792b2532362b6c74787471756572792b7468726f7567682b657870726573736976652b456c6f7175656e74266d643d312673686f7757617465726d61726b3d3026666f6e7453697a653d313030707826696d616765733d68747470732533412532462532466c61726176656c2e636f6d253246696d672532466c6f676f6d61726b2e6d696e2e737667)[![Latest Version on Packagist](https://camo.githubusercontent.com/207d3e1e0903ec3958ee19268fb8fe06bc2a95d571784422eb98205686d59faa/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f68617070656e762d636f6d2f6c61726176656c2d6c747265652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/happenv-com/laravel-ltree)[![Total Downloads](https://camo.githubusercontent.com/a1ceac05bf8caade0d9186cabb759bbd7a6105f524f32bfc4cd5665572d24b24/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f68617070656e762d636f6d2f6c61726176656c2d6c747265652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/happenv-com/laravel-ltree)[![Tests](https://camo.githubusercontent.com/ce7049143f0b53c07530a492d548ca890d5726def65ae0e87f708356f3eeca90/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f68617070656e762d636f6d2f6c61726176656c2d6c747265652f74657374732e796d6c3f6272616e63683d312e78267374796c653d666c61742d737175617265266c6162656c3d7465737473)](https://github.com/happenv-com/laravel-ltree/actions/workflows/tests.yml)[![Mutation](https://camo.githubusercontent.com/9eb2e45510cd70bb2dcce675b3af0d7f4d93dbd80ba3e658dd06b2964e622b8e/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f68617070656e762d636f6d2f6c61726176656c2d6c747265652f6d75746174696f6e2e796d6c3f6272616e63683d312e78267374796c653d666c61742d737175617265266c6162656c3d6d75746174696f6e)](https://github.com/happenv-com/laravel-ltree/actions/workflows/mutation.yml)[![PHPStan](https://camo.githubusercontent.com/3d1cfb0a4b315bca63da64b3cfa75e6bd51294eaa13b747d1d4992453cd52003/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f68617070656e762d636f6d2f6c61726176656c2d6c747265652f7068707374616e2e796d6c3f6272616e63683d312e78267374796c653d666c61742d737175617265266c6162656c3d7068707374616e)](https://github.com/happenv-com/laravel-ltree/actions/workflows/phpstan.yml)[![Zizmor](https://camo.githubusercontent.com/0bb03a9b14ffa5b166139c4af8ea802444e634661a58c945599174d0dd908a7a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f68617070656e762d636f6d2f6c61726176656c2d6c747265652f7a697a6d6f722e796d6c3f6272616e63683d312e78267374796c653d666c61742d737175617265266c6162656c3d7a697a6d6f72)](https://github.com/happenv-com/laravel-ltree/actions/workflows/zizmor.yml)[![Code Style](https://camo.githubusercontent.com/bb3c33f4c46c20241b798311a9ad1776400d80d2d6557619b898b552ae41ee2e/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f68617070656e762d636f6d2f6c61726176656c2d6c747265652f6669782d636f64652d7374796c652e796d6c3f6272616e63683d312e78267374796c653d666c61742d737175617265266c6162656c3d636f64652532307374796c65)](https://github.com/happenv-com/laravel-ltree/actions/workflows/fix-code-style.yml)

[![PHP 8.3+](https://camo.githubusercontent.com/3e917f814dbf22a3899830f503ea03f6952ebe4e81a6bf4e5c537aec91242283/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d382e332532422d3737376262343f7374796c653d666c61742d737175617265)](https://www.php.net/)[![Laravel 12 | 13](https://camo.githubusercontent.com/cbef5a2edd6eb43b4547ed337ee084403e443037560cbc25099582d9c7530263/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d313225323025374325323031332d6666326432303f7374796c653d666c61742d737175617265)](https://laravel.com/)[![PostgreSQL 16–18](https://camo.githubusercontent.com/6815db8f672edbe5f53bf4f8d78cccea84a7536a3728949329235bc90771a33e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f706f737467726573716c2d31362d2d31382d3333363739313f7374796c653d666c61742d737175617265)](https://www.postgresql.org/docs/current/ltree.html)

> **This package is not another tree implementation. PostgreSQL already provides one.****This package exposes it through an expressive Laravel API.**

PostgreSQL's [`ltree`](https://www.postgresql.org/docs/current/ltree.html) extension stores hierarchies as materialized paths and indexes them with GiST. Subtree membership, ancestor lookups, pattern matching and depth queries become single, index-backed operators — no recursive CTEs, no `lft`/`rgt` renumbering, no N+1.

`laravel-ltree` gives you that power through Eloquent: a typed query builder, real (eager-loadable) relations, transactional tree operations, `lquery`/`ltxtquery` pattern matching, a tree-aware collection, route-model binding, and Artisan tooling — all fully typed (PHPStan level max), with **no raw SQL in your application code**.

```
$category->descendants;                              // eager relation, one query
Category::descendantsOf($electronics)->count();      // path get();     // lquery
$laptop->moveTo($peripherals);                       // transactional subtree move
```

---

Table of contents
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Migrations](#migrations)
- [Basic usage](#basic-usage)
- [Reading the tree](#reading-the-tree)
- [Relationships](#relationships)
- [The query builder](#the-query-builder)
- [The `wherePath` DSL](#the-wherepath-dsl)
- [lquery — path pattern matching](#lquery--path-pattern-matching)
- [ltxtquery — full-text label matching](#ltxtquery--full-text-label-matching)
- [Tree operations](#tree-operations)
- [Collections](#collections)
- [Finders &amp; route-model binding](#finders--route-model-binding)
- [The `LtreePath` value object](#the-ltreepath-value-object)
- [Testing helpers](#testing-helpers)
- [Artisan commands](#artisan-commands)
- [Configuration](#configuration)
- [Indexes](#indexes)
- [Performance](#performance)
- [Best practices](#best-practices)
- [Why not `parent_id`?](#why-not-parent_id)
- [Why not nested sets?](#why-not-nested-sets)
- [Events](#events)
- [FAQ](#faq)
- [Quality](#quality)
- [License](#license)

---

Requirements
------------

[](#requirements)

- PHP **8.3+**
- Laravel **12 or 13**
- PostgreSQL **16, 17, or 18** (the suite is verified to pass identically on all three)

Installation
------------

[](#installation)

```
composer require happenv-com/laravel-ltree
```

The service provider is auto-discovered. Then run the installer, which publishes the config file and creates the `ltree` extension on your default connection:

```
php artisan ltree:install
```

`ltree:install` accepts `--no-extension` (skip `CREATE EXTENSION`, e.g. when your database user lacks the privilege and a DBA will create it) and `--force` (overwrite an existing published config). To publish the config manually:

```
php artisan vendor:publish --tag=ltree-config
```

Migrations
----------

[](#migrations)

The package registers Blueprint macros so your migrations read naturally. A table backed by ltree typically keeps a `parent_id` adjacency column too — it's optional but recommended (see [below](#the-parent_id-column)):

```
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->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete();

            $table->ltree('path')->nullable();   // the materialized path
            $table->ltreeDepth();                 // STORED generated column: nlevel(path)
            $table->gist('path');                 // GiST index — the index that makes ltree fast
        });
    }
};
```

The Blueprint macros:

MacroColumn type`$table->ltree('path')``ltree``$table->lquery('col')``lquery``$table->ltxtquery('col')``ltxtquery``$table->ltreeDepth('depth', from: 'path')``integer GENERATED ALWAYS AS (nlevel(path)) STORED``$table->gist('path')`GiST index using `gist_ltree_ops``$table->gin('path')`**throws** `UnsupportedIndexException` — there is no GIN opclass for a scalar `ltree`; use `gist()``path` is nullable because, with the default primary-key label, a new row's path can only be built once its auto-increment id exists — the package backfills it immediately after insert (see [Basic usage](#basic-usage)). The `ltreeDepth()` column is a real stored column PostgreSQL keeps in sync, so ordering and filtering by depth never call `nlevel()` at query time.

Basic usage
-----------

[](#basic-usage)

Add the `HasLtree` trait to any Eloquent model:

```
use Happenv\Ltree\Concerns\HasLtree;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasLtree;

    protected $guarded = [];
}
```

That's the whole setup. The package maintains `path` for you from the model's **label** and its parent. By default the label is the model's primary key, so paths look like `1`, `1.5`, `1.5.23`:

```
$electronics = Category::create(['name' => 'Electronics']);            // path: "1"
$computers   = Category::create(['name' => 'Computers', 'parent_id' => $electronics->id]); // path: "1.2"
$laptops     = Category::create(['name' => 'Laptops', 'parent_id' => $computers->id]);     // path: "1.2.3"
```

Set the parent by `parent_id`, or with the fluent helper before saving:

```
$laptops = new Category(['name' => 'Laptops']);
$laptops->setLtreeParent($computers)->save();          // path derived from $computers
```

### Human-readable labels

[](#human-readable-labels)

Prefer slugs in the path (`electronics.computers.laptops`)? Override two hooks — return your column as the label, and tell the package the label no longer depends on the primary key:

```
class Category extends Model
{
    use HasLtree;

    public function getLtreeLabel(): string
    {
        return $this->normalizeLtreeLabel($this->slug);   // e.g. "gaming-laptops"
    }

    public function getLtreeLabelDependsOnKey(): bool
    {
        return false;   // label comes from the slug, not the auto-increment id
    }
}
```

`normalizeLtreeLabel()` runs the value through the configured [normalizer](#configuration), which by default replaces characters that are illegal in an ltree label (a hyphen becomes an underscore: `gaming-laptops` → `gaming_laptops`). Switch the normalizer to the `throw` strategy to reject illegal labels with an `InvalidLabelException` instead.

### The `path` attribute

[](#the-path-attribute)

`path` is cast to an immutable [`LtreePath`](#the-ltreepath-value-object) value object, and `path()` returns it (or `null` before the row is persisted):

```
$laptops->path();              // LtreePath("1.2.3")
(string) $laptops->path();     // "1.2.3"
$laptops->path()->depth();     // 3
```

Reading the tree
----------------

[](#reading-the-tree)

Every model gets a set of read helpers that answer structural questions without loading relations:

```
$node->depth();            // nlevel(path) — 1 for a root
$node->level();            // alias of depth()
$node->isRoot();           // depth === 1
$node->isLeaf();           // has no descendants
$node->hasParent();
$node->hasChildren();
$node->hasDescendants();
$node->countChildren();    // direct children
$node->countDescendants(); // whole subtree, excluding self
$node->branch();           // query builder for this node's whole subtree (self + descendants)
```

Relationships
-------------

[](#relationships)

All eight relations are real Eloquent relations — they eager-load (`with(...)`), count, and work with `whereHas`. The path-based ones issue **one query for an entire batch**, never one per parent:

```
$node->parent;              // BelongsTo (via parent_id)
$node->children;            // HasMany  (via parent_id)

$node->descendants;         // strict descendants  (path descendantsAndSelf;  // descendants including the node
$node->ancestors;           // strict ancestors    (path @> node), root-first
$node->ancestorsAndSelf;    // ancestors including the node
$node->siblings;            // same parent, excluding self
$node->root;                // the top-most (depth-1) ancestor; a root's root is itself
```

Eager loading a forest is a single extra query regardless of how many nodes you load:

```
$roots = Category::whereNull('parent_id')->with('descendants')->get();  // 2 queries total
```

`whereHas` works because the relations compile to correlated ltree predicates:

```
Category::whereHas('descendants', fn ($q) => $q->where('name', 'Laptops'))->get();
Category::whereHas('children')->get();   // only nodes that actually have children
```

The query builder
-----------------

[](#the-query-builder)

`Category::query()` returns a typed `LtreeBuilder`, so every predicate below is a first-class, IDE-completed method (not a global macro) and PHPStan resolves it at level max. Each predicate accepts a `Model`, an `LtreePath`, or a raw path `string`.

**Structural**

```
Category::query()->whereRoot();
Category::query()->whereLeaf();
Category::query()->whereAncestorOf($node);
Category::query()->whereDescendantOf($node);
Category::query()->whereChildOf($node);
Category::query()->whereParentOf($node);
Category::query()->whereSiblingOf($node);
Category::query()->whereDescendantOfAny([$a, $b, $c]);   // union across several nodes, one query
Category::query()->whereAncestorOfAny([$a, $b]);
```

**Depth**

```
Category::query()->whereDepth(3);
Category::query()->whereBetweenDepth(2, 4);
Category::query()->maxDepth(3);
Category::query()->minDepth(2);
Category::query()->whereWithinDepthOf($node, 2);         // node + up to 2 levels below
Category::query()->orderByDepth('desc');
Category::query()->withDepth();                          // adds a "depth" column
```

**Path shape**

```
Category::query()->wherePathStartsWith($ancestor);       // :ancestor @> path
Category::query()->wherePathEndsWith('Laptops');         // lquery *.Laptops
Category::query()->whereSegment('Gaming');               // label anywhere in the path
```

**Aggregates &amp; utilities**

```
Category::query()->whereKey([$a->id, $b->id])->commonAncestor();  // LtreePath|null (lca)
Category::query()->tapPath(fn ($q) => $q->where('active', true)); // tap, keep chaining
```

**Query scopes** — the same building blocks as convenient static entry points, each returning an `LtreeBuilder`:

```
Category::roots()->get();
Category::leaves()->get();
Category::descendantsOf($node)->get();
Category::ancestorsOf($node)->get();
Category::childrenOf($node)->get();
Category::siblingsOf($node)->get();
```

The `wherePath` DSL
-------------------

[](#the-wherepath-dsl)

Compose several path constraints in one grouped `WHERE` with a fluent, closure-based builder — handy when you want the constraints isolated from surrounding `orWhere` clauses:

```
Category::wherePath(fn ($path) => $path
    ->descendantOf($electronics)
    ->depth(3)
    ->contains('Laptops'))
    ->get();
```

The closure receives a `PathConstraint` exposing every predicate as a chainable verb: `descendantOf`, `ancestorOf`, `childOf`, `parentOf`, `siblingOf`, `root`, `leaf`, `depth`, `betweenDepth`, `maxDepth`, `minDepth`, `withinDepth`, `startsWith`, `endsWith`, `segment`, `matches`, `matchesAny`, `contains`, `containsAll`, `containsAny`.

lquery — path pattern matching
------------------------------

[](#lquery--path-pattern-matching)

[`lquery`](https://www.postgresql.org/docs/current/ltree.html#LTREE-LQUERY) matches a path against a pattern with wildcards (`*`), quantifiers (`*{1,2}`), negation (`!`), and alternation:

```
Category::wherePathMatches('*.Gaming.*')->get();          // Gaming anywhere in the path
Category::wherePathMatches('Top.*{1}.Laptops')->get();    // exactly one label between Top and Laptops
Category::wherePathMatches('*.!Physics')->get();          // last label is not "Physics"

Category::wherePathMatches('*.Science.*')
    ->orWherePathMatches('*.Hobbies.*')
    ->get();

Category::wherePathMatchesAny(['*.Astronomy', '*.Physics'])->get();  // match ANY of several lqueries
```

`lquery` is case-sensitive by default; append the `@` flag to a label to make just that label case-insensitive: `wherePathMatches('*.science@.*')`.

ltxtquery — full-text label matching
------------------------------------

[](#ltxtquery--full-text-label-matching)

[`ltxtquery`](https://www.postgresql.org/docs/current/ltree.html#LTREE-LTXTQUERY) matches whole labels anywhere in the path with boolean operators. The safe helpers validate each word so an operator can't be smuggled in and silently change the query's meaning:

```
Category::wherePathContains('Gaming')->get();                     // has a "Gaming" label
Category::wherePathContainsAll(['Gaming', 'Laptop'])->get();      // Gaming AND Laptop
Category::wherePathContainsAny(['Laptop', 'Desktop'])->get();     // Laptop OR Desktop
```

Words match a **whole label** (so `Gam` does not match `Gaming`); append `*` for a prefix (`Gam*`) or `@` for case-insensitivity (`gaming@`). For the full raw grammar (`&`, `|`, `!`, grouping) use `search()`:

```
Category::search('Gaming & !Console')->get();
```

Tree operations
---------------

[](#tree-operations)

Every mutating operation runs inside a transaction (toggle with the `transactional_moves` config), fires cancellable "before" events and "after" events, and uses PostgreSQL's own path arithmetic — a subtree move or rename is a **single `UPDATE`**, not a row-by-row walk.

```
$laptops->moveTo($peripherals);   // reparent the whole subtree; guards against moving under itself
$laptops->detach();               // make it a new root (moveTo(null))
$copy = $laptops->copyTo($store); // deep-copy the subtree with new keys; returns the new root

$node->appendChild($child);       // attach as a child
$node->prependChild($child);

$branch->cascadeDelete();         // DELETE the whole subtree; alias: deleteBranch()
$node->renameSegment('notebooks');// rename this node's label; cascades to every descendant path

Category::query()->first()->rebuildPaths();  // recompute every path from parent_id (repair / migration)
```

Moving under your own descendant throws an `InvalidMoveException`.

### Ordered siblings (opt-in)

[](#ordered-siblings-opt-in)

Sibling ordering is opt-in. Add an integer column, point `order_column` at it (config or per model), and you get ordered moves:

```
// config/ltree.php  →  'order_column' => 'sort_order'   (default is null = unordered)

$node->moveBefore($sibling);
$node->moveAfter($sibling);
$node->moveFirst();
$node->moveLast();
```

Calling an ordered move without a configured order column throws a `MissingSortColumnException`.

Collections
-----------

[](#collections)

Query results come back as an `LtreeCollection` with tree-shaping helpers — all in memory, plus two that hit the database once for the whole set:

```
$flat = Category::query()->whereDescendantOf($root)->get();

$tree = $flat->toTree();     // nest into a tree; each node's `children` relation is populated
$tree->flatten();            // inverse of toTree()
$flat->roots();              // members with no parent in the set
$flat->leaves();             // members that aren't a parent of another member
$flat->sortTree();           // depth-first (path) order

$flat->descendants();        // ONE query: every descendant of every member, minus the members
$flat->ancestors();          // ONE query: every ancestor of every member
```

Finders &amp; route-model binding
---------------------------------

[](#finders--route-model-binding)

```
Category::findByPath('1.2.3');     // ?Category by path string
Category::findByLtree($ltreePath); // ?Category by LtreePath
Category::findDescendantsOf($node);// LtreeCollection
Category::findAncestorsOf($node);  // LtreeCollection
```

Bind a route to a model **by path** with the standard `{model:field}` syntax:

```
Route::get('/categories/{category:path}', fn (Category $category) => $category);
// GET /categories/1.2.3  → resolves the category whose path is "1.2.3"
```

Non-path fields (and nested `resolveChildRouteBinding`) fall back to Laravel's default behavior, so `{category}` still binds by key.

The `LtreePath` value object
----------------------------

[](#the-ltreepath-value-object)

`LtreePath` is a framework-independent, immutable (`final readonly`) value object — `Countable`, `IteratorAggregate`, `Stringable`, `JsonSerializable`. It never touches the database:

```
use Happenv\Ltree\ValueObjects\LtreePath;

$path = new LtreePath('electronics.computers.laptops');

$path->segments();                 // ['electronics', 'computers', 'laptops']
$path->depth();                    // 3
$path->parent();                   // LtreePath("electronics.computers") | null at the root
$path->root();                     // LtreePath("electronics")
$path->first();  $path->last();    // "electronics" / "laptops"
$path->append('gaming');           // LtreePath("...laptops.gaming")  (returns a new instance)
$path->prepend('catalog');
$path->slice(1, 2);

$path->contains('computers');      // true
$path->startsWith('electronics');  // true
$path->endsWith('laptops');        // true
$path->isAncestorOf($other);
$path->isDescendantOf($other);
$path->equals($other);
$path->compareLexically($other);   // -1 | 0 | 1, for sorting
(string) $path;                    // "electronics.computers.laptops"
```

Testing helpers
---------------

[](#testing-helpers)

Build trees in tests and seeders without writing nested `create()` calls. `TreeBuilder::fromArray()` takes a nested `children` structure and returns the flat `LtreeCollection` of everything it created:

```
use Happenv\Ltree\Testing\TreeBuilder;

TreeBuilder::fromArray(Category::class, [
    ['name' => 'Electronics', 'children' => [
        ['name' => 'Computers', 'children' => [
            ['name' => 'Laptops'],
            ['name' => 'Desktops'],
        ]],
        ['name' => 'Phones'],
    ]],
    ['name' => 'Books'],
]);
```

Add the `HasLtreeFactory` trait to a model's factory for a factory-native API:

```
use Happenv\Ltree\Testing\HasLtreeFactory;

class CategoryFactory extends Factory
{
    use HasLtreeFactory;
    // ...
}

// Build a standalone tree from the factory:
Category::factory()->createTree([...]);

// Or attach a subtree beneath each created parent — mirrors Laravel's has():
Category::factory()->hasTree([
    ['children' => [[], []]],
])->create();
```

Artisan commands
----------------

[](#artisan-commands)

```
php artisan ltree:install                       # publish config + create the ltree extension
php artisan ltree:check "App\Models\Category"   # integrity diagnostic (see below)
php artisan ltree:rebuild "App\Models\Category" # recompute every path from parent_id
php artisan ltree:optimize "App\Models\Category"# ensure the GiST index, then REINDEX + ANALYZE
```

`ltree:check` verifies the extension is installed, the path column has a GiST index, and — reporting a non-zero exit code on any integrity problem — that there are no `NULL` paths, no orphaned paths (a node whose parent path is missing), no dangling `parent_id` references, and that `path` and `parent_id` agree. It fails cleanly (no stack trace) on an unmigrated table.

Configuration
-------------

[](#configuration)

`config/ltree.php`:

```
return [
    'path_column'          => 'path',
    'parent_column'        => 'parent_id',   // set null to run purely on ltree, no adjacency mirror
    'order_column'         => null,          // set to e.g. 'sort_order' to enable ordered siblings
    'auto_update_path'     => true,          // maintain `path` automatically via the model observer
    'auto_create_extension'=> false,         // CREATE EXTENSION during migrations
    'default_index'        => 'gist',
    'transactional_moves'  => true,          // wrap tree operations in a transaction
    'cascade_delete'       => false,
    'fk_on_delete'         => 'restrict',
    'normalizer' => [
        'class'        => \Happenv\Ltree\Normalization\DefaultNormalizer::class,
        'strategy'     => 'replace',         // 'replace' illegal chars, or 'throw'
        'replacements' => ['-' => '_'],
    ],
];
```

Any of the per-model column hooks (`getLtreePathColumn`, `getLtreeParentColumn`, `getLtreeOrderColumn`) can be overridden on a model to diverge from the global config.

Indexes
-------

[](#indexes)

The single index that matters is a **GiST** index on the path column (`$table->gist('path')`). It backs every containment (``), `lquery` (`~`), and `ltxtquery` (`@`) operator.

There is **no GIN operator class for a scalar `ltree`** in PostgreSQL — GIN only applies to `ltree[]` (arrays of paths). Calling `$table->gin('path')` therefore throws `UnsupportedIndexException` with a message pointing you at `gist()`, rather than silently creating an index that can't serve ltree queries. `ltree:optimize` creates the GiST index if it's missing.

Performance
-----------

[](#performance)

The `benchmarks/` directory contains a runnable harness comparing this package's `ltree` approach against a plain `parent_id` adjacency list queried with recursive CTEs. Indicative numbers from a 50,000-node tree on PostgreSQL 18 (see [`benchmarks/RESULTS.md`](benchmarks/RESULTS.md) for the full, caveated results):

Operationltreeadjacency + recursive CTEdescendants of a node**faster** (single GiST probe)recursive walk, one join per levelancestors of a nodetied (both bounded by depth)tiedsubtree moverewrites the subtree's paths**faster** (one pointer update)branch delete**faster** (`DELETE … WHERE path
