PostgreSQL’s ltree extension stores hierarchical label-tree paths (e.g. Top.Sports.Football) and supports ancestor/descendant queries with GiST indexes. It also ships two companion query types — lquery for path patterns and ltxtquery for full-text style label queries.
Stores a single hierarchical path. Maps to MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree in PHP.
useDoctrine\ORM\MappingasORM;useMartinGeorgiev\Doctrine\DBAL\Type;useMartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;#[ORM\Entity]classCategory{#[ORM\Column(type: Type::LTREE)]privateLtree$path;}// Setting a path$category->path=Ltree::fromString('Top.Sports.Football');// Working with paths$path=Ltree::fromString('Top.Sports.Football');$path->isDescendantOf(Ltree::fromString('Top.Sports'));// true$path->getParent();// Top.Sports$path->withLeaf('UEFA');// Top.Sports.Football.UEFA
🗃️ Doctrine can’t define GiST indexes with the required ltree operator classes. Create the index manually in a migration:
Matches 1 to 2 labels; *{2}, *{2,} and *{,2} are also valid
a\|b
Matches label a or label b
!a\|b
Matches any label that is neither a nor b
a{1,2}
Quantifiers also apply to non-star items
a@
Case-insensitive match
a*
Prefix match
a%
Match against a _-separated word inside the label
🗃️ PostgreSQL normalizes the modifier order on storage, so sport*@ is read back as sport@*.
lquery[]
Stores an array of lquery patterns. Maps to array<string> in PHP. Null elements are supported.
The array form is what the MATCHES_ANY_LQUERY operator consumes.
$filter->patterns=['Top.*','!football|tennis'];
ltxtquery
Stores a full-text style query over the labels of an ltree value. Maps to string in PHP.
Words are combined with & (and), | (or) and ! (not), and may be grouped with parentheses.
Each word accepts the same @, * and % modifiers as lquery labels.
🗃️ PostgreSQL normalizes operator spacing on storage, so Earth&Moon is read back as Earth & Moon.
ltxtquery[]
Stores an array of ltxtquery queries. Maps to array<string> in PHP. Null elements are supported.
No PostgreSQL operator consumes this type — it is provided so that collections of saved queries can be persisted in a single column.
All three return a boolean and must be compared with = TRUE or = FALSE when used in a DQL WHERE clause.
The pattern argument is cast to lquery / ltxtquery in the generated SQL, so plain DQL string literals and bound parameters work without any further ceremony.
MATCHES_LQUERY(ltree, lquery)
Checks whether the path matches a single lquery pattern.
$dql="SELECT e FROM Entity e WHERE MATCHES_LQUERY(e.path, 'Top.*{1,2}.Football') = TRUE";// 'Top.Sports.Football' ~ 'Top.*{1,2}.Football' → true
MATCHES_ANY_LQUERY(ltree, lquery[])
Checks whether the path matches any pattern in an array of lquery patterns.
$dql="SELECT e FROM Entity e WHERE MATCHES_ANY_LQUERY(e.path, ARRAY('Top.Sports.*', 'Top.Culture.*')) = TRUE";
MATCHES_LTXTQUERY(ltree, ltxtquery)
Checks whether the labels of the path satisfy an ltxtquery.
$dql="SELECT e FROM Entity e WHERE MATCHES_LTXTQUERY(e.path, 'Sports & !Football') = TRUE";// 'Top.Sports.Basketball' @ 'Sports & !Football' → true
DQL Examples
// All descendants of Top.Sports$dql="SELECT e FROM Entity e WHERE MATCHES_LQUERY(e.path, 'Top.Sports.*') = TRUE";// All ancestors of a given path$dql="SELECT e FROM Entity e WHERE IS_CONTAINED_BY(TEXT2LTREE('Top.Sports.Football'), e.path) = TRUE";// Entities at depth 2$dql="SELECT e FROM Entity e WHERE NLEVEL(e.path) = 2";// Parent path$dql="SELECT SUBPATH(e.path, 0, NLEVEL(e.path) - 1) FROM Entity e";// Longest common ancestor of two entities$dql="SELECT LCA(e1.path, e2.path) FROM Entity e1, Entity e2 WHERE e1.id = 1 AND e2.id = 2";// Everything under Top.Sports, at most two levels deep$dql="SELECT e FROM Entity e WHERE MATCHES_LQUERY(e.path, 'Top.Sports.*{1,2}') = TRUE";// Paths mentioning Sports but not Football$dql="SELECT e FROM Entity e WHERE MATCHES_LTXTQUERY(e.path, 'Sports & !Football') = TRUE";// Pattern supplied as a bound parameter$dql="SELECT e FROM Entity e WHERE MATCHES_LQUERY(e.path, :pattern) = TRUE";
Performance
Use GiST indexes on ltree columns
IS_CONTAINED_BY (<@) and CONTAINS (@>) use those indexes automatically, as do MATCHES_LQUERY (~), MATCHES_ANY_LQUERY (?) and MATCHES_LTXTQUERY (@)
SUBPATH with negative offsets is efficient for parent extraction
LCA is well-suited for finding shared ancestors in hierarchical queries