Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Ltree Laravel Package

umbrellio/laravel-ltree

Laravel integration for PostgreSQL ltree that brings hierarchical tree data to Eloquent models. Provides migrations, path helpers, and query scopes for ancestors, descendants, and subtree traversal, making tree-structured data easy to store and search.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is small, PostgreSQL-only, and mostly a set of macros + traits rather than a full ORM layer. The fastest path to a working setup:

  1. composer require umbrellio/laravel-ltree. The service provider is auto-discovered on Laravel 8+; on older versions add Umbrellio\LTree\LTreeServiceProvider::class to config/app.php.
  2. Enable the extension at the database level — this requires a privileged role once per database, not per project:
    CREATE EXTENSION IF NOT EXISTS ltree;
    
    Put it in a "database bootstrap" migration that runs before your tree tables, or bake it into your Docker/provisioning init script.
  3. Add the column using the registered Blueprint macro:
    Schema::create('pages', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('parent_id')->nullable();
        $table->ltree('path')->nullable();
        $table->string('name');
        $table->timestamps();
    });
    
  4. On the model, implement the package's LTreeModelInterface and use LTreeModelTrait (check src/Interfaces and src/Traits for the current names). The trait wires up parent(), children(), ancestors(), descendants() relationships and the path-maintenance helpers.
  5. First use case to validate the whole setup: a categories or menu tree. Create a root, create a child, then assert Page::whereLtreeDescendantOf($root->path)->count() === 2.

Where to look first:

  • README.md for the canonical snippet and the exact trait/interface names.
  • src/LTreeQueryBuilder.php (or equivalent) — this is where the whereDescendantOf / whereAncestorOf / whereMatches macros are defined. That file is the real API surface.
  • The test suite; it doubles as documentation for move/moveToSibling/delete behavior.

Implementation Patterns

Model shape. Keep parent_id and path. path is the source of truth for reads; parent_id is the source of truth for writes. Never update parent_id directly — always go through the trait's move helpers so path stays consistent.

class Page extends Model implements LTreeModelInterface
{
    use LTreeModelTrait;

    protected $fillable = ['parent_id', 'path', 'name'];
}

Subtree queries (the day-to-day bread and butter).

// Everything under a node, including itself
Page::whereDescendantOf($page->path)->orderBy('path')->get();

// Breadcrumb trail up to the root
Page::whereAncestorOf($page->path)->orderBy('path')->get();

// Pattern match — children of "docs" at any depth, one level below root
Page::whereMatches('*.docs.*')->get();

// Depth, without loading the tree
Page::whereDescendantOf($path)->orderByRaw('nlevel(path)')->get();

ORDER BY path gives you a stable depth-first ordering for free — it's the cheapest "tree render" you'll ever write, and it's what makes this package worth it over a recursive CTE.

Moving and re-parenting. A move is a single UPDATE using subpath/string replacement on the path prefix, wrapped in a transaction. The trait exposes move helpers; call them inside DB::transaction() yourself if you're moving several nodes, and lock the subtree with ->lockForUpdate() first when concurrent edits are possible.

Path generation for new nodes. Don't hand-roll paths from slugs. The common pattern is: create the row inside a transaction, then let the trait append the id (or a sanitized slug) as the last label. If you want human-readable paths (blog.2019.posts), sanitize every segment — preg_replace('/[^a-zA-Z0-9_]/', '_', $slug) — because ltree labels reject anything else.

Integration with routes and menus. Bind by path instead of id for readable URLs (/docs/getting-started/laravel), and cache a full SELECT id, path, name FROM pages ORDER BY path result for the nav — it's one row per node and re-renders trivially.

Multi-tenant scoping. Prefix the root path with the tenant id (tenant_42.pages.home). All descendant queries remain scoped automatically as long as you always start from the tenant's root path.

Gotchas and Tips

  • Label sanitization is the #1 source of runtime exceptions. ltree labels allow only [A-Za-z0-9_], cannot be empty, max 1000 chars each, and paths max 65535 bytes. Hyphens, dots, spaces, and unicode slugs will throw a PostgreSQL error at insert time, not at validation time — add a form request rule or a model mutator.
  • Index choice matters. The default ->index() is a btree and will not accelerate @> / <@ queries. Add a GiST index explicitly:
    DB::statement('CREATE INDEX pages_path_gist ON pages USING GIST (path)');
    
    Without it, descendant queries become sequential scans the moment you cross a few thousand rows.
  • SQLite will not work. If your test suite uses SQLite-in-memory, this package is unusable there. Point phpunit.xml at a real PostgreSQL instance; otherwise every feature test touching the tree dies at CREATE EXTENSION.
  • Don't update path from parent_id triggers. You'll fight the trait. If you need DB-level enforcement, replicate the package's move logic in a trigger — but then you have two sources of truth.
  • Case sensitivity. ltree comparisons are case-sensitive (Docs ≠ docs); a slug normalized in PHP but not in the path generates duplicate branches silently.
  • Deep eager loading is a trap. ->with('children.children.children') produces correct results but explodes into N+1. For rendering, prefer the flat ORDER BY path query and reconstruct the tree in PHP — it's one query and typically faster than lazy-loading relationships.
  • Locking on move. Two concurrent re-parents of overlapping subtrees will corrupt paths. DB::transaction(fn () => Page::whereDescendantOf($path)->lockForUpdate()->... ) is cheap insurance.
  • Debugging paths. Reach for raw SQL when something looks wrong: SELECT path, nlevel(path), subpath(path, 0, 1) FROM pages; is far more informative than dumping the Eloquent collection.
  • Extension points. The trait is designed to be overridden — override the path-segment generator to use slugs instead of ids, and override the query builder macro class if you need additional operators (~ lquery, ? ltxtquery) that the package doesn't ship. Register custom macros via Builder::macro() in a service provider; the package's own macros follow the same mechanism, so they compose cleanly.
  • Pin the version. With 35 stars the bus factor is low; every release since 2025 is worth reading the diff on before upgrading, particularly around Laravel major-version support.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource