Clarification on usage of ILIKE, CONTAINS, IS_CONTAINED_BY, DATE_OVERLAPS and other operator-like functions
Error: Expected =, <, <=, <>, >, >=, !=, got 'ILIKE' (the column number depends on your query) is probably one of the most common DQL errors you may experience when working with this library. The cause for this is that when parsing the DQL Doctrine won’t recognize ILIKE as a known operator. In fact ILIKE is registered as a boolean function.
Doctrine doesn’t provide easy support for implementing custom operators. This may change in the future but for now it is easier to trick the DQL parser with a boolean expression.
Example intent with PostgreSQL:
SELECT*FROMemailsWHEREsubjectILIKE'Test email';
Intuitively, one may assume the below DQL. However it will not work:
-- Basic usage with string literals and entity propertiesSELECTJSON_BUILD_OBJECT('name',e.userName,'email',e.userEmail)FROMUsere-- Multiple key-value pairsSELECTJSONB_BUILD_OBJECT('id',e.id,'status','active','type',e.userType)FROMEmployeee-- Invalid usage (will not work):SELECTJSON_BUILD_OBJECT('count',COUNT(*))-- Aggregate functions not supportedSELECTJSONB_BUILD_OBJECT('number',123)-- All number types, NULL and boolean values not supported currently
Note: Keys must always be string literals, while values can be either string literals or object property references.
Using JSON Path Functions
PostgreSQL 12+ introduced JSON path functions that provide a powerful way to query JSON data. Here are some examples:
-- Check if a JSON path exists with a conditionSELECTeFROMEntityeWHEREJSONB_PATH_EXISTS(e.jsonData,'$.items[*] ? (@.price > 100)')=TRUE-- Check if a JSON path matches a conditionSELECTeFROMEntityeWHEREJSONB_PATH_MATCH(e.jsonData,'exists($.items[*] ? (@.price >= 50 && @.price <= 100))')=TRUE-- Extract all items matching a path querySELECTe.id,JSONB_PATH_QUERY(e.jsonData,'$.items[*].name')FROMEntitye-- Extract all items as an arraySELECTe.id,JSONB_PATH_QUERY_ARRAY(e.jsonData,'$.items[*].id')FROMEntitye-- Extract the first item matching a path querySELECTe.id,JSONB_PATH_QUERY_FIRST(e.jsonData,'$.items[*] ? (@.featured == true)')FROMEntitye
Using Regular Expression Functions
PostgreSQL 15+ introduced additional regular expression functions that provide more flexibility when working with text data:
📖 See also: Text and Pattern Functions for complete regular expression and text processing documentation
-- Count occurrences of a patternSELECTe.id,REGEXP_COUNT(e.text,'\d{3}-\d{2}-\d{4}')asssn_countFROMEntitye-- Find position of a patternSELECTe.id,REGEXP_INSTR(e.text,'important')aspositionFROMEntitye-- Extract substring matching a patternSELECTe.id,REGEXP_SUBSTR(e.text,'https?://[\w.-]+')asurlFROMEntitye
Using Date Functions
Newer PostgreSQL versions introduced additional date functions (DATE_BIN in 14, DATE_ADD and DATE_SUBTRACT in 16) that provide more flexibility when working with dates and timestamps:
-- Bin timestamps into 15-minute intervalsSELECTDATE_BIN('15 minutes',e.createdAt,'2001-01-01')FROMEntitye-- Add an interval to a timestamp (timezone parameter is optional)SELECTDATE_ADD(e.timestampWithTz,'1 day')FROMEntityeSELECTDATE_ADD(e.timestampWithTz,'1 day','Europe/London')FROMEntitye-- Subtract an interval from a timestamp (timezone parameter is optional)SELECTDATE_SUBTRACT(e.timestampWithTz,'2 hours')FROMEntityeSELECTDATE_SUBTRACT(e.timestampWithTz,'2 hours','UTC')FROMEntitye-- Truncate a timestamp to a specified precision (timezone parameter is optional)SELECTDATE_TRUNC('day',e.timestampWithTz)FROMEntityeSELECTDATE_TRUNC('day',e.timestampWithTz,'UTC')FROMEntitye
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-- 95th percentile response time, always a value that was actually recordedSELECTPERCENTILE_DISC(0.95WITHINGROUPORDERBYr.durationMs)ASp95FROMRequestr-- The fraction can be a parameterSELECTPERCENTILE_CONT(:fractionWITHINGROUPORDERBYr.durationMs)ASpercentileFROMRequestr-- Most common status per category; MODE takes no fraction, so the call starts with WITHIN GROUPSELECTe.category,MODE(WITHINGROUPORDERBYe.status)AScommonStatusFROMEntityeGROUPBYe.category
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-- 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-- The library's own aggregates wrap the same waySELECTp.id,FILTER(ARRAY_AGG(t.nameORDERBYt.name),WHEREt.archived=FALSE)ASactiveTagsFROMPostpJOINp.tagstGROUPBYp.id-- Filter in HAVING too: categories with more than ten active itemsSELECTe.categoryFROMEntityeGROUPBYe.categoryHAVINGFILTER(COUNT(e.id),WHEREe.active=TRUE)>10
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)
-- Running total per customerSELECTo.id,OVER(SUM(o.amount),PARTITIONBYo.customerORDERBYo.createdAtROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW)ASrunningTotalFROMOrdero-- Seven-day moving averageSELECTd.day,OVER(AVG(d.visits),ORDERBYd.dayRANGEBETWEEN'6 days'PRECEDINGANDCURRENTROW)ASweeklyAverageFROMDailyStatd-- Each order's share of its customer's totalSELECTo.id,o.amount/OVER(SUM(o.amount),PARTITIONBYo.customer)ASshareFROMOrdero-- Paid revenue per customer next to every order, with FILTER inside OVER as in SQLSELECTo.id,OVER(FILTER(SUM(o.amount),WHEREo.status='paid'),PARTITIONBYo.customer)ASpaidTotalFROMOrdero-- Each order next to the customer's previous order amount, 0 for their first orderSELECTo.id,o.amount,OVER(LAG(o.amount,1,0),PARTITIONBYo.customerORDERBYo.createdAt)ASpreviousAmountFROMOrdero
The ranking functions (ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILE) exist only inside OVER:
-- Number each customer's orders, newest firstSELECTo.id,OVER(ROW_NUMBER(),PARTITIONBYo.customerORDERBYo.createdAtDESC)ASorderNumberFROMOrdero-- Rank players by score; tied players share a rankSELECTp.name,OVER(RANK(),ORDERBYp.scoreDESC)ASscoreRankFROMPlayerp
// 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-- Find products available in a specific periodSELECTpFROMProductpWHERECONTAINS(p.availabilityPeriod,DATERANGE('2024-06-01','2024-06-30'))=TRUE-- 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
Using PostgreSQL Composite Types
PostgreSQL composite types allow you to define custom structured types with named fields. This library provides the COMPOSITE_FIELD function to access fields from composite type columns 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-- Use composite fields in WHERE clausesSELECTpFROMProductpWHERECOMPOSITE_FIELD(p.item,'price')>10.00
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.
Using PostGIS Types with Doctrine DBAL (Geometry/Geography)
useMartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;// 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();
Dimensional modifiers are supported and normalized:
POINTZ(1 2 3) => POINT Z(1 2 3)
LINESTRINGM(0 0 1, 1 1 2) => LINESTRING M(0 0 1, 1 1 2)
POLYGONZM((...)) => POLYGON ZM((...))
POINT Z (1 2 3) => POINT Z(1 2 3)
Using PostGIS Spatial Operators in DQL
PostGIS spatial operators allow you to perform spatial queries using bounding box relationships and distance calculations. Important: All spatial operators return boolean values and shall be used with = TRUE or = FALSE in DQL.
-- Find geometries to the left of a reference pointSELECTeFROMEntityeWHERESTRICTLY_LEFT(e.geometry,'POINT(0 0)')=TRUE-- Find geometries that spatially contain a point (bounding box level)SELECTeFROMEntityeWHERESPATIAL_CONTAINS(e.polygon,'POINT(1 1)')=TRUE-- Find geometries contained within a bounding boxSELECTeFROMEntityeWHERESPATIAL_CONTAINED_BY(e.geometry,'POLYGON((0 0, 10 10, 20 20, 0 0))')=TRUE-- Check if two geometries have the same bounding boxSELECTeFROMEntityeWHERESPATIAL_SAME(e.geometry1,e.geometry2)=TRUE-- Vertical relationshipsSELECTeFROMEntityeWHERESTRICTLY_ABOVE(e.geometry,'LINESTRING(0 0, 5 0)')=TRUESELECTeFROMEntityeWHEREOVERLAPS_BELOW(e.geometry,'POLYGON((0 5, 5 5, 5 10, 0 10, 0 5))')=TRUE-- 3D spatial relationshipsSELECTeFROMEntityeWHEREND_OVERLAPS(e.geometry3d,'POLYGON Z((0 0 0, 1 1 1, 2 2 2, 0 0 0))')=TRUE
Distance-Based Queries
-- Find the nearest geometries to a pointSELECTe,GEOMETRY_DISTANCE(e.geometry,'POINT(0 0)')asdistanceFROMEntityeORDERBYdistance-- Find geometries within a specific distance (using bounding box distance for performance)SELECTeFROMEntityeWHEREBOUNDING_BOX_DISTANCE(e.geometry,'POINT(0 0)')<1000-- Calculate trajectory distances (for linestrings with measure values)SELECTTRAJECTORY_DISTANCE(e.trajectory1,e.trajectory2)asclosest_approachFROMEntityeWHEREe.trajectory1ISNOTNULL-- 3D distance calculationsSELECTe,ND_CENTROID_DISTANCE(e.geometry3d1,e.geometry3d2)asdistance3dFROMEntityeWHEREND_CENTROID_DISTANCE(e.geometry3d1,e.geometry3d2)<500
Operator Conflicts and Best Practices
Some operators have different meanings for different data types. Use specific function names to avoid conflicts:
-- ✅ CORRECT: Use specific function namesSELECTeFROMEntityeWHERECONTAINS(e.tags,ARRAY('tag1'))=TRUE-- Array containmentSELECTeFROMEntityeWHERESPATIAL_CONTAINS(e.polygon,e.point)=TRUE-- Spatial containmentSELECTeFROMEntityeWHEREREGEXP(e.text,'pattern')=TRUE-- Text pattern matching-- ❌ AVOID: Ambiguous usage that might conflict-- The @ and ~ operators have different meanings for arrays vs spatial data
Performance Tips
-- Use bounding box operators for initial filtering (they use spatial indexes)SELECTeFROMEntityeWHEREOVERLAPS(e.geometry,'POLYGON((0 0, 10 10, 20 20, 0 0))')=TRUEANDST_Intersects(e.geometry,'POLYGON((0 0, 10 10, 20 20, 0 0))')=TRUE-- Exact check-- Use distance operators for nearest neighbor queriesSELECTeFROMEntityeORDERBYGEOMETRY_DISTANCE(e.geometry,'POINT(0 0)')
Values round-trip as EWKT/WKT strings at the database boundary.
Integration tests automatically enable the postgis extension; ensure PostGIS is available in your environment.
Hierarchical Data with ltree
📖 See also: ltree Types for type reference and DQL functions
This example shows a self-referential entity with ltree path management and cascading path updates in Symfony.
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());}}
🗃️ Create the GiST index manually in a migration — Doctrine can’t generate ltree-specific operator class syntax:
⚠️ Changing an entity’s parent requires cascading the path change to all descendants — Doctrine does not handle this automatically. Use an onFlush listener:
```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);