martin-georgiev/postgresql-for-doctrine
Adds PostgreSQL-specific power to Doctrine DBAL/ORM: rich native types (jsonb, arrays, ranges, network, geometric, etc.) plus DQL functions/operators for JSON and array querying. Supports PostgreSQL 9.4+ and PHP 8.2+.
PostgreSQL's ltree extension stores hierarchical label-tree paths (e.g. Top.Sports.Football) and supports ancestor/descendant queries with GiST/GIN indexes.
📖 See also: Available Types | Ltree Functions and Operators | Hierarchical Data with
ltree
The ltree extension must be enabled in PostgreSQL:
CREATE EXTENSION IF NOT EXISTS ltree;
In Symfony, add this to the beginning of the up() method in any migration that introduces an ltree column:
$this->addSql('CREATE EXTENSION IF NOT EXISTS ltree');
use MartinGeorgiev\Doctrine\DBAL\Type;
use MartinGeorgiev\Doctrine\DBAL\Types\Ltree;
use MartinGeorgiev\Doctrine\DBAL\Types\LtreeArray;
Type::addType(Type::LTREE, Ltree::class);
Type::addType(Type::LTREE_ARRAY, LtreeArray::class);
Stores a single hierarchical path. Maps to MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree in PHP.
use Doctrine\ORM\Mapping as ORM;
use MartinGeorgiev\Doctrine\DBAL\Type;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
#[ORM\Entity]
class Category
{
#[ORM\Column(type: Type::LTREE)]
private Ltree $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 or GIN indexes with the required ltree operator classes. Create the index manually in a migration:
CREATE INDEX category_path_gist_idx ON category USING GIST (path gist_ltree_ops(siglen=100));
-- or
CREATE INDEX category_path_gin_idx ON category USING GIN (path gin_ltree_ops);
Stores an array of ltree paths. Maps to array<Ltree> in PHP. Null elements are supported.
use Doctrine\ORM\Mapping as ORM;
use MartinGeorgiev\Doctrine\DBAL\Type;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
#[ORM\Entity]
class Article
{
#[ORM\Column(type: Type::LTREE_ARRAY)]
private array $tags = [];
}
// Setting paths
$article->tags = [
Ltree::fromString('Top.Sports.Football'),
Ltree::fromString('Top.Sports.Basketball'),
];
📖 See also: AVAILABLE-FUNCTIONS-AND-OPERATORS.md for the full function index
SUBLTREE(ltree, start, end)Extracts a subpath from position start to end-1 (counting from 0).
$dql = "SELECT SUBLTREE(e.path, 1, 2) FROM Entity e";
// subltree('Top.Child1.Child2', 1, 2) → 'Child1'
SUBPATH(ltree, offset, len)Extracts a subpath starting at offset with length len. Supports negative values.
$dql = "SELECT SUBPATH(e.path, 0, 2) FROM Entity e";
// subpath('Top.Child1.Child2', 0, 2) → 'Top.Child1'
$dql = "SELECT SUBPATH(e.path, -2) FROM Entity e";
// subpath('Top.Child1.Child2', -2) → 'Child1.Child2'
SUBPATH(ltree, offset)Extracts from offset to the end.
$dql = "SELECT SUBPATH(e.path, 1) FROM Entity e";
// subpath('Top.Child1.Child2', 1) → 'Child1.Child2'
NLEVEL(ltree)Returns the number of labels in the path.
$dql = "SELECT NLEVEL(e.path) FROM Entity e";
// nlevel('Top.Child1.Child2') → 3
INDEX(a, b)Returns the position of the first occurrence of b in a, or -1 if not found.
$dql = "SELECT INDEX(e.path, 'Child1') FROM Entity e";
// index('Top.Child1.Child2', 'Child1') → 1
INDEX(a, b, offset)Same as above, but starts searching from offset.
$dql = "SELECT INDEX(e.path, 'Child1', 1) FROM Entity e";
LCA(ltree1, ltree2, ...)Computes the longest common ancestor (up to 8 arguments).
$dql = "SELECT LCA(e.path1, e.path2, e.path3) FROM Entity e";
// lca('Top.Child1.Child2', 'Top.Child1', 'Top.Child2') → 'Top'
TEXT2LTREE(text)Casts text to ltree.
$dql = "SELECT e FROM Entity e WHERE e.path <@ TEXT2LTREE('Top.Sports')";
LTREE2TEXT(ltree)Casts ltree to text.
$dql = "SELECT LTREE2TEXT(e.path) FROM Entity e";
// All descendants of Top.Sports
$dql = "SELECT e FROM Entity e WHERE e.path <@ TEXT2LTREE('Top.Sports')";
// All ancestors of a given path
$dql = "SELECT e FROM Entity e WHERE TEXT2LTREE('Top.Sports.Football') <@ e.path";
// 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";
ltree columns<@ and @> operators use those indexes automaticallySUBPATH with negative offsets is efficient for parent extractionLCA is well-suited for finding shared ancestors in hierarchical queriesHow can I help you explore Laravel packages today?