This page shows common tasks as DQL, each with the result it returns from the sample rows below, with times in UTC. Each -- → line under a query is one row of its result. A query without its own ORDER BY has its rows listed in sample-row order, or by group.
The sample rows behind the results
EmailEntity id
subject
1
Test Email
2
test email
3
Welcome
User 1 has userNameada and userEmailada@example.com; Employee 7 has userTypecontractor.
Request holds 20 rows, with durationMs from 10 to 200 in steps of 10.
Post id
tags
1
php, legacy, doctrine
2
legacy
Of the Tag rows, only legacy is archived.
DailyStat day
visits
2026-09-01
100
2026-09-02
140
2026-09-03
90
2026-09-08
70
Player id
name
score
1
Ada
90
2
Grace
95
3
Linus
90
Product id
priceRange
availabilityPeriod
item
1
[10.50,99.99)
[2024-01-01,2024-12-31)
(Widget,1,9.99)
2
[60,80)
[2024-06-10,2024-07-10)
(Gadget,2,24.50)
Operators such as ILIKE are functions in DQL
Error: Expected =, <, <=, <>, >, >=, !=, got 'ILIKE' (the column number depends on your query) is one of the most common DQL errors with this library. ILIKE is not one of the operators DQL knows, and Doctrine has no way to add one, so this library registers ILIKE as a boolean function instead. A boolean function needs a comparison.
CONTAINS, IS_CONTAINED_BY, DATE_OVERLAPS and the other operators this library adds work the same way.
Using JSON_BUILD_OBJECT and JSONB_BUILD_OBJECT
Both functions take keys and values in turns. Each argument can be a string literal, a field, or an expression such as an aggregate. A number, TRUE, FALSE or NULL written straight into the DQL does not parse. A bare parameter parses, but PostgreSQL rejects it with could not determine data type of parameter, so wrap it in CAST:
-- Bin timestamps into 15-minute intervalsSELECTDATE_BIN('15 minutes',e.createdAt,'2001-01-01')FROMEntitye-- → [1 => '2026-09-27 10:30:00+00']-- [1 => '2026-09-27 10:45:00+00']-- [1 => '2026-09-27 11:00:00+00']-- Add an interval to a timestamp (timezone parameter is optional)SELECTDATE_ADD(e.timestampWithTz,'1 day')FROMEntitye-- → [1 => '2026-03-29 22:30:00+00']-- [1 => '2026-10-25 12:00:00+00']-- [1 => '2026-09-28 08:15:00+00']SELECTDATE_ADD(e.timestampWithTz,'1 day','Europe/London')FROMEntitye-- → [1 => '2026-03-29 21:30:00+00']-- [1 => '2026-10-25 13:00:00+00']-- [1 => '2026-09-28 08:15:00+00']-- Subtract an interval from a timestamp (timezone parameter is optional)SELECTDATE_SUBTRACT(e.timestampWithTz,'2 hours')FROMEntitye-- → [1 => '2026-03-28 20:30:00+00']-- [1 => '2026-10-24 10:00:00+00']-- [1 => '2026-09-27 06:15:00+00']SELECTDATE_SUBTRACT(e.timestampWithTz,'2 hours','UTC')FROMEntitye-- → [1 => '2026-03-28 20:30:00+00']-- [1 => '2026-10-24 10:00:00+00']-- [1 => '2026-09-27 06:15:00+00']-- Truncate a timestamp to a specified precision (timezone parameter is optional)SELECTDATE_TRUNC('day',e.timestampWithTz)FROMEntitye-- → [1 => '2026-03-28 00:00:00+00']-- [1 => '2026-10-24 00:00:00+00']-- [1 => '2026-09-27 00:00:00+00']SELECTDATE_TRUNC('day',e.timestampWithTz,'UTC')FROMEntitye-- → [1 => '2026-03-28 00:00:00+00']-- [1 => '2026-10-24 00:00:00+00']-- [1 => '2026-09-27 00:00:00+00']
Medians, percentiles and the most common value
PostgreSQL computes these with ordered-set aggregates, written in SQL as percentile_cont(0.5) WITHIN GROUP (ORDER BY e.value). DQL cannot parse anything after a function’s closing parenthesis, so the WITHIN GROUP ORDER BY part moves inside the call, with no comma before it and no parentheses around it:
-- SQL: percentile_cont(0.5) WITHIN GROUP (ORDER BY o.total)-- DQL: PERCENTILE_CONT(0.5 WITHIN GROUP ORDER BY o.total)
-- Median order value per customer (interpolated between the two middle values)SELECTc.id,PERCENTILE_CONT(0.5WITHINGROUPORDERBYo.total)ASmedianTotalFROMOrderoJOINo.customercGROUPBYc.id-- → ['id' => 1, 'medianTotal' => 40.0]-- ['id' => 2, 'medianTotal' => 100.0]-- 95th percentile response time, always a value that was actually recordedSELECTPERCENTILE_DISC(0.95WITHINGROUPORDERBYr.durationMs)ASp95FROMRequestr-- → ['p95' => 190]-- The fraction can be a parameterSELECTPERCENTILE_CONT(:fractionWITHINGROUPORDERBYr.durationMs)ASpercentileFROMRequestr-- → ['percentile' => 181.0]-- Most common status per category; MODE takes no fraction, so the call starts with WITHIN GROUPSELECTe.category,MODE(WITHINGROUPORDERBYe.status)AScommonStatusFROMEntityeGROUPBYe.category-- → ['category' => 'books', 'commonStatus' => 'active']-- ['category' => 'music', 'commonStatus' => 'active']
ORDER BY takes exactly one item; PostgreSQL rejects more.
The fraction must not read an ungrouped column: use a literal, a parameter, or columns listed in GROUP BY.
Aggregating only some rows with FILTER
PostgreSQL restricts the rows a single aggregate reads with FILTER (WHERE ...), written in SQL after the aggregate: COUNT(o.id) FILTER (WHERE o.status = 'paid'). DQL cannot parse anything after a function’s closing parenthesis, so FILTER wraps the aggregate instead, with the condition as its second argument:
-- Several conditional counts in one pass, instead of one query per statusSELECTc.id,COUNT(o.id)ASallOrders,FILTER(COUNT(o.id),WHEREo.status='paid')ASpaidOrders,FILTER(COUNT(o.id),WHEREo.status='refunded')ASrefundedOrdersFROMOrderoJOINo.customercGROUPBYc.id-- → ['id' => 1, 'allOrders' => 3, 'paidOrders' => 2, 'refundedOrders' => 1]-- ['id' => 2, 'allOrders' => 1, 'paidOrders' => 1, 'refundedOrders' => 0]-- Revenue this year next to all-time revenue; the condition can take parametersSELECTc.id,SUM(o.total)ASallTime,FILTER(SUM(o.total),WHEREo.placedAt>=:startOfYear)ASthisYearFROMOrderoJOINo.customercGROUPBYc.id-- → ['id' => 1, 'allTime' => '120.00', 'thisYear' => '80.00']-- ['id' => 2, 'allTime' => '100.00', 'thisYear' => '100.00']-- The library's own aggregates wrap the same waySELECTp.id,FILTER(ARRAY_AGG(t.nameORDERBYt.name),WHEREt.archived=FALSE)ASactiveTagsFROMPostpJOINp.tagstGROUPBYp.id-- → ['id' => 1, 'activeTags' => '{doctrine,php}']-- ['id' => 2, 'activeTags' => null]-- Filter in HAVING too: categories with more than ten active itemsSELECTe.categoryFROMEntityeGROUPBYe.categoryHAVINGFILTER(COUNT(e.id),WHEREe.active=TRUE)>10-- → no rows: no category has more than ten active entities in the sample
The first argument must be an aggregate; a scalar function or a nested FILTER throws a ParserException.
Running totals and moving averages with OVER
PostgreSQL runs an aggregate over a window of rows with OVER (...), written in SQL after the call: SUM(o.amount) OVER (PARTITION BY o.customer ORDER BY o.createdAt). DQL cannot parse anything after a function’s closing parenthesis, so OVER wraps the call instead, with the window specification as its second argument:
-- SQL: SUM(o.amount) OVER (PARTITION BY o.customer ORDER BY o.createdAt)-- DQL: OVER(SUM(o.amount), PARTITION BY o.customer ORDER BY o.createdAt)
// Entity with range fields#[ORM\Entity]classProduct{#[ORM\Column(type: 'numrange')]private?NumericRange$priceRange=null;#[ORM\Column(type: 'daterange')]private?DateRange$availabilityPeriod=null;}// Create ranges$product=newProduct();$product->setPriceRange(newNumericRange(10.50,99.99));$product->setAvailabilityPeriod(newDateRange(new\DateTimeImmutable('2024-01-01'),new\DateTimeImmutable('2024-12-31')));// Check if values are in rangeif($product->getPriceRange()->contains(25.00)){echo"Price is in range";}
-- Find products with overlapping price rangesSELECTpFROMProductpWHEREOVERLAPS(p.priceRange,NUMRANGE('20','50'))=TRUE-- → Product {id: 1}-- Find products available in a specific periodSELECTpFROMProductpWHERECONTAINS(p.availabilityPeriod,DATERANGE('2024-06-01','2024-06-30'))=TRUE-- → Product {id: 1}-- Find products whose price range contains 25.0 (a bare '25.0' would be read as a range literal and rejected)SELECTpFROMProductpWHERECONTAINS(p.priceRange,NUMRANGE('25.0','25.0','[]'))=TRUE-- → Product {id: 1}
Using PostgreSQL composite types
A composite type groups named fields into one column. COMPOSITE_FIELD reads a single field of it in DQL.
-- Create a composite type for inventory itemsCREATETYPEinventory_itemAS(nameTEXT,supplier_idINTEGER,priceNUMERIC(10,2));-- Create a table using the composite typeCREATETABLEproducts(idSERIALPRIMARYKEY,iteminventory_item);-- Insert data using ROW constructorINSERTINTOproducts(item)VALUES(ROW('Widget',1,9.99));
Accessing composite fields in DQL
-- Access a field from a composite typeSELECTCOMPOSITE_FIELD(p.item,'name')FROMProductp-- → [1 => 'Widget']-- [1 => 'Gadget']-- Use composite fields in WHERE clausesSELECTpFROMProductpWHERECOMPOSITE_FIELD(p.item,'price')>10.00-- → Product {id: 2}
Entity configuration
Map the column to a subclass of Composite registered under the composite type’s name - here an InventoryItemType registered as inventory_item. Composite types shows how to create and register it.
useDoctrine\DBAL\Types\TypeasDoctrineType;useMartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;DoctrineType::addType('geometry',MartinGeorgiev\Doctrine\DBAL\Types\Geometry::class);DoctrineType::addType('geometry[]',MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray::class);DoctrineType::addType('geography',MartinGeorgiev\Doctrine\DBAL\Types\Geography::class);// Insert a single geometry value$qb=$connection->createQueryBuilder();$qb->insert('places')->values(['location'=>':wktSpatialData']);$qb->setParameter('wktSpatialData',WktSpatialData::fromString('POINT(1 2)'),'geometry');$qb->executeStatement();// Insert a single geography value with SRID$qb=$connection->createQueryBuilder();$qb->insert('places')->values(['boundary'=>':wktSpatialData']);$qb->setParameter('wktSpatialData',WktSpatialData::fromString('SRID=4326;POINT(-122.4194 37.7749)'),'geography');$qb->executeStatement();// Insert a single-item geometry[] array$qb=$connection->createQueryBuilder();$qb->insert('routes')->values(['geometriesLines'=>':wktSpatialData']);$qb->setParameter('wktSpatialData',[WktSpatialData::fromString('LINESTRING(0 0, 1 1)')],'geometry[]');$qb->executeStatement();
A value reads back with its dimensional modifier spelled POINT Z(1 2 3), whether you wrote POINT Z (1 2 3) or POINTZ(1 2 3); see How dimensional modifiers are spelled.
The PostGIS operators compare bounding boxes or measure distances. The bounding box operators return booleans and must be compared with = TRUE or = FALSE in DQL; the distance operators return numbers.
-- Find geometries to the left of a reference pointSELECTeFROMEntityeWHERESTRICTLY_LEFT(e.geometry,'POINT(0 0)')=TRUE-- → Entity {id: 1}-- Find geometries that spatially contain a point (bounding box level)SELECTeFROMEntityeWHERESPATIAL_CONTAINS(e.polygon,'POINT(1 1)')=TRUE-- → Entity {id: 1}-- Entity {id: 3}-- Find geometries contained within a bounding boxSELECTeFROMEntityeWHERESPATIAL_CONTAINED_BY(e.geometry,'POLYGON((0 0, 20 0, 20 20, 0 20, 0 0))')=TRUE-- → Entity {id: 2}-- Check if two geometries have the same bounding boxSELECTeFROMEntityeWHERESPATIAL_SAME(e.geometry1,e.geometry2)=TRUE-- → Entity {id: 1}-- Entity {id: 3}-- Vertical relationshipsSELECTeFROMEntityeWHERESTRICTLY_ABOVE(e.geometry,'LINESTRING(0 0, 5 0)')=TRUE-- → Entity {id: 1}-- Entity {id: 2}-- Entity {id: 3}SELECTeFROMEntityeWHEREOVERLAPS_BELOW(e.geometry,'POLYGON((0 5, 5 5, 5 10, 0 10, 0 5))')=TRUE-- → Entity {id: 1}-- Entity {id: 3}-- n-D bounding boxes overlapSELECTeFROMEntityeWHEREND_OVERLAPS(e.geometry3d,'POLYGON Z((0 0 0, 1 0 0, 1 1 1, 0 0 0))')=TRUE-- → Entity {id: 1}
Distance-based queries
-- Find the nearest geometries to a pointSELECTe,GEOMETRY_DISTANCE(e.geometry,'POINT(0 0)')asdistanceFROMEntityeORDERBYdistance-- → [0 => Entity {id: 1}, 'distance' => 3.1622776601683795]-- [0 => Entity {id: 2}, 'distance' => 12.36931687685298]-- [0 => Entity {id: 3}, 'distance' => 30.01666203960727]-- Geometries whose bounding box lies within 1000 units of the point; for an exact distance use ST_DWITHINSELECTeFROMEntityeWHEREBOUNDING_BOX_DISTANCE(e.geometry,'POINT(0 0)')<1000-- → Entity {id: 1}-- Entity {id: 2}-- Entity {id: 3}-- Calculate trajectory distances (for linestrings with measure values)SELECTTRAJECTORY_DISTANCE(e.trajectory1,e.trajectory2)asclosest_approachFROMEntityeWHEREe.trajectory1ISNOTNULL-- → ['closest_approach' => 5.0]-- n-D distance between the bounding box centroidsSELECTe,ND_CENTROID_DISTANCE(e.geometry3d1,e.geometry3d2)asdistance3dFROMEntityeWHEREND_CENTROID_DISTANCE(e.geometry3d1,e.geometry3d2)<500-- → [0 => Entity {id: 1}, 'distance3d' => 13.0]-- [0 => Entity {id: 3}, 'distance3d' => 1.0]
Operators with several meanings
Containment is @> for arrays and ~ for geometries, and ~ also matches a regular expression against text. Each meaning has its own DQL name:
SELECTeFROMEntityeWHERECONTAINS(e.tags,ARRAY('tag1'))=TRUE-- Array containmentSELECTeFROMEntityeWHERESPATIAL_CONTAINS(e.polygon,e.point)=TRUE-- Spatial containmentSELECTeFROMEntityeWHEREREGEXP(e.text,'pattern')=TRUE-- Text pattern matching
Using the spatial index
-- ST_INTERSECTS checks the bounding boxes against the spatial index by itself; no pre-filter is neededSELECTeFROMEntityeWHEREST_INTERSECTS(e.geometry,'POLYGON((0 0, 20 0, 20 20, 0 20, 0 0))')=TRUE-- → Entity {id: 2}-- Nearest first; with setMaxResults() PostgreSQL reads the nearest rows straight from the GiST indexSELECTeFROMEntityeORDERBYGEOMETRY_DISTANCE(e.geometry,'POINT(0 0)')-- → Entity {id: 1}-- Entity {id: 2}-- Entity {id: 3}
An entity that points to its parent and keeps its ltree path in step with it, plus a Symfony listener that moves the descendants along when a node moves.
Entity
<?phpdeclare(strict_types=1);namespaceApp\Entity;useDoctrine\Common\Collections\ArrayCollection;useDoctrine\Common\Collections\Collection;useDoctrine\ORM\MappingasORM;useMartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;useSymfony\Bridge\Doctrine\Types\UuidType;useSymfony\Component\Uid\Uuid;/**
* Manually edit `my_entity_path_gist_idx` in migration to use GiST.
* Declaring the index using Doctrine attributes prevents its removal during migrations.
*/#[ORM\Entity]#[ORM\Index(columns: ['path'], name: 'my_entity_path_gist_idx')]classMyEntityimplements\Stringable{#[ORM\Column(type: UuidType::NAME)]#[ORM\GeneratedValue(strategy: 'NONE')]#[ORM\Id]privateUuid$id;#[ORM\Column(type: 'ltree')]privateLtree$path;/** @var Collection<array-key, MyEntity> */#[ORM\OneToMany(targetEntity: MyEntity::class, mappedBy: 'parent')]privateCollection$children;publicfunction__construct(#[ORM\Column(unique: true, length: 128)]privatestring$name,#[ORM\ManyToOne(targetEntity: MyEntity::class, inversedBy: 'children')]private?MyEntity$parent=null,){$this->id=Uuid::v7();$this->children=newArrayCollection();$this->path=Ltree::fromString($this->id->toBase58());if($parentinstanceofMyEntity){$this->setParent($parent);}}publicfunction__toString():string{return$this->name;}publicfunctiongetId():Uuid{return$this->id;}publicfunctiongetParent():?MyEntity{return$this->parent;}publicfunctiongetName():string{return$this->name;}publicfunctiongetPath():Ltree{return$this->path;}/** @return Collection<array-key, MyEntity> */publicfunctiongetChildren():Collection{return$this->children;}publicfunctionsetName(string$name):void{$this->name=$name;}publicfunctionsetParent(MyEntity$parent):void{if($parent->getId()->equals($this->id)){thrownew\InvalidArgumentException("Parent can't be self");}if($parent->getPath()->isDescendantOf($this->getPath())){thrownew\InvalidArgumentException("Parent can't be a descendant of the current node");}$this->parent=$parent;$this->path=$parent->getPath()->withLeaf($this->id->toBase58());}}
Doctrine cannot declare an index with an ltree operator class, so create it in a migration:
When a node gets a new parent, every descendant’s path has to change too. Doctrine does not do that for you; an onFlush listener can:
```php
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\MyEntity;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\UnitOfWork;
#[AsDoctrineListener(event: Events::onFlush, priority: 500, connection: ‘default’)]
final readonly class MyEntityOnFlushListener
{
public function onFlush(OnFlushEventArgs $eventArgs): void
{
$entityManager = $eventArgs->getObjectManager();
$unitOfWork = $entityManager->getUnitOfWork();
$entityMetadata = $entityManager->getClassMetadata(MyEntity::class);