pvsaintpe/ltree-bundle
pvsaintpe/ltree-bundle is a Symfony bundle for integrating PostgreSQL ltree with Doctrine, enabling hierarchical tree data storage, path-based queries, and practical tree management for PHP applications.
First, the reality check: this is a Symfony bundle (pvsaintpe/ltree-bundle), not a Laravel package. It wraps PostgreSQL's ltree extension into Doctrine DBAL types and helpers. In Laravel you'd be adapting the underlying concepts (custom DBAL type, migration, query builder) rather than using the bundle as-is. If you're on Symfony, install normally via Composer and register the bundle.
Minimum steps (Conceptual / Laravel adaptation):
Enable the extension in Postgres — this is the true prerequisite:
CREATE EXTENSION IF NOT EXISTS ltree;
The bundle assumes ltree is already available; it does not install it.
Add an ltree column to your table:
ALTER TABLE categories ADD COLUMN path ltree;
CREATE INDEX categories_path_gist ON categories USING GIST (path);
The GiST index is what makes @> / <@ queries fast. A plain btree is nearly useless for ltree matching.
Register the type so your ORM/framework treats ltree as a string, not as a blob/unknown:
LtreeType), or manually add the DBAL type.->selectRaw('path::text')) since Eloquent has no native ltree support. Write the DBAL type yourself — it's ~20 lines (getName(): 'ltree', getSQLDeclaration(): 'ltree', and treat values as strings).Read the source first. With only 10 stars and a 2021 last release, the most useful "documentation" is the bundle's DependencyInjection/Configuration.php and the ltree SQL type class. Those tell you the exact column shape and helper signatures you're relying on.
First use case: hierarchical categories or org units. Store paths like root.electronics.phones, then:
-- all descendants of 'root.electronics'
SELECT * FROM categories WHERE path <@ 'root.electronics';
-- direct children only
SELECT * FROM categories WHERE path <@ 'root.electronics' AND nlevel(path) = 3;
Path conventions. Build paths from stable, immutable segments (id or slug), joined with .. The path is the hierarchy — treat it like a materialized path, not a foreign key. Renaming a node's label means rewriting every descendant path.
Subtree move is the core operation. Do it in a single transaction:
UPDATE categories
SET path = new_prefix || subpath(path, nlevel(old_prefix))
WHERE path <@ old_prefix;
Always scope with <@ old_prefix and wrap in a transaction — a partial update corrupts the tree.
Query patterns to standardize in a repository:
path <@ :anchorpath @> :nodenlevel(path)ORDER BY pathpath ~ 'root.category.*{1}'Pair ltree with a plain FK/parent_id. ltree gives you fast subtree reads; keep parent_id for cheap single-row parent lookups and referential integrity. They serve different jobs.
Integrate at the repository layer, not the model. Whether Doctrine or Eloquent, hide ltree syntax behind methods like descendantsOf(), moveSubtree(), depthOf(). Raw ltree operators leak easily and are hard to test.
Migrations must own both the extension and the index. Add CREATE EXTENSION IF NOT EXISTS ltree as the first statement so fresh environments (CI, migrate:fresh) don't fail on a missing type.
This package is stale. Last release March 2021, 10 stars. Pin it explicitly, and verify it against your current Symfony/Doctrine major version before adopting. If anything breaks, you'll be maintaining a fork. The ltree SQL itself is stable across Postgres versions, so falling back to hand-written types + queries is often lower risk than depending on the bundle.
Label rules are strict. ltree labels are limited to alphanumerics, underscores, and (newer Postgres) hyphens. Dots are separators — you cannot include a literal dot in a label. Slugs with dots or spaces will silently mangle your tree. Sanitize segments before building paths.
Length limits. Each label is capped (255 bytes) and the whole path too; deep trees of long slugs hit this faster than expected.
lquery vs ltree vs ltext. The ~ operator expects an lquery; passing a plain ltree won't work. When constructing lquery patterns dynamically, quote/escape user input — lquery has metacharacters (*, @, !, |, {}) that invite injection or accidental wildcards.
Index choice matters. Without a GiST index, every <@/@> becomes a sequential scan. Confirm with EXPLAIN ANALYZE after adding data — small dev tables hide this.
Case sensitivity. ltree comparisons are case-sensitive. Decide on lowercase normalization at write time or you'll get split trees (root.Phones vs root.phones).
Moving subtrees is the #1 bug source. Forgetting the transaction, or matching with LIKE 'old.%' instead of an ltree operator, will catch sibling prefixes (root.cat matching root.category) and corrupt data. Use subpath() + <@, never string LIKE.
No ORM magic. The bundle provides a DBAL type; it does not give you an entity tree behavior, cascade, or events. All hierarchy maintenance (path recalculation on insert/move/delete) is yours. Centralize it in a service or model observer.
Debugging tip: when a query returns nothing unexpectedly, SELECT path::text, nlevel(path) FROM t to eyeball actual stored paths — invisible escaping/quoting problems are common.
How can I help you explore Laravel packages today?