happenv-com/laravel-ltree
Laravel integration for PostgreSQL's ltree extension, making it easy to store and query hierarchical data such as categories, menus, and org trees. Supports path-based ancestor/descendant lookups, subtree moves, and Eloquent model traits for managing tree structures directly in your database.
This package brings PostgreSQL's ltree extension into Eloquent, giving you materialized-path style hierarchies (categories, org charts, comment threads, menus) with native @> / <@ operators and GiST indexing instead of recursive CTEs.
Minimal path to first value:
ltree contrib extension available on the server. SQLite and MySQL are not supported — full stop. Check with SELECT * FROM pg_available_extensions WHERE name = 'ltree'; before installing anything.composer require happenv-com/laravel-ltreeltree column in your own migration, then add the trait to your model.First use case to validate the integration end-to-end: a categories table where each row stores a dotted path like electronics.phones.android. Create a root, attach two children, then assert that Category::descendantsOf($root) returns the subtree. If that works, everything else is variation on the same theme.
Since v1.0.0 is the first stable tag and ships with only a changelog link, treat the README and test suite as the authoritative API reference and pin the version ("happenv-com/laravel-ltree": "^1.0") in composer.json.
Schema. Enable the extension once per database (CREATE EXTENSION IF NOT EXISTS ltree;) — usually in an early migration, not per-table. The path column should be ltree typed, not text, or you lose operator and index support. Add a GiST index on it in the same migration:
DB::statement('CREATE INDEX categories_path_gist ON categories USING GIST (path);');
Model. Add the package trait to the model and point it at your path column and the attribute used to build labels (typically slug or id). Keep the path column out of $fillable — it should only ever be written by the package, otherwise you get orphaned subtrees.
Creating nodes. Prefer the package's append/child helpers over hand-assigning paths. A hand-rolled $parent->path . '.' . $slug skips the uniqueness and validation logic and breaks the moment two nodes share a slug.
Querying. The two scopes you'll use daily are descendants (path <@ ?) and ancestors (path @> ?). Reach for these instead of whereHas recursion — they're a single index-backed scan. For depth-limited queries, nlevel(path) gives you the depth and composes cleanly into orderBy.
Rendering trees. For menus and select boxes, fetch the subtree in one flat query ordered by path and build the nested array in PHP. One query per level is the anti-pattern this package exists to eliminate.
Moving subtrees. Re-parenting is a single UPDATE ... SET path = new_prefix || subpath(path, nlevel(old_prefix)) touching all descendants at once. Use the package's move helper if it exposes one; if not, wrap the raw statement in a transaction and lock the affected rows (lockForUpdate) — concurrent inserts under the old parent during a move will corrupt the tree.
Backfilling existing data. If you're migrating from parent_id, do it in a maintenance window or behind a feature flag. Order rows by depth (roots first), compute paths in PHP, and write them in bulk — the trait's per-model save path is too slow for large tables.
ltree labels allow alphanumerics and underscores (and hyphens on PG 16+, but not as a leading character). Dots are separators and cannot appear in a label. If your slugs contain dots or unicode, sanitize them or use numeric IDs as labels and keep the human-readable slug in a separate column.ltree paths max out at 65,535 bytes with a limit on label count per path. Deep trees with long slugs will eventually fail on insert. Numeric labels sidestep this entirely.@> and <@ degrade to sequential scans. Confirm it's used with EXPLAIN ANALYZE on a realistic table size before you trust the performance claims.CREATE EXTENSION, that step belongs in provisioning, not in a migration that runs on every deploy.DB::listen) for any scope that surprises you. The generated ltree expressions are the fastest way to see whether the bug is in your label generation or the query.How can I help you explore Laravel packages today?