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.
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:
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.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.Blueprint macro:
Schema::create('pages', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable();
$table->ltree('path')->nullable();
$table->string('name');
$table->timestamps();
});
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.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.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.
[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() 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.phpunit.xml at a real PostgreSQL instance; otherwise every feature test touching the tree dies at CREATE EXTENSION.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.Docs ≠ docs); a slug normalized in PHP but not in the path generates duplicate branches silently.->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.DB::transaction(fn () => Page::whereDescendantOf($path)->lockForUpdate()->... ) is cheap insurance.SELECT path, nlevel(path), subpath(path, 0, 1) FROM pages; is far more informative than dumping the Eloquent collection.~ 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.How can I help you explore Laravel packages today?