Expression Syntax
An expression is the fundamental unit passed to where(), andWhere(), orWhere(), having(), and the bridge appliers. It encodes what to filter, how to filter it, and (optionally) how to combine multiple filters into one string.
Single Expression: path?filter
A single expression has the form:
path?filter
The ? is the mandatory separator between the path (which column or relationship to target) and the filter (which operator and value to apply).
price?>1000
^───── ^────
path filter
Path
The path identifies a column. For a simple column in the driving table, it is just the column name:
price
status
created_at
For a column in a related table, segments are chained with double underscores __:
invoices__customers__name
For more details about the path syntax — including aliases, join conditions, and subquery paths — see Path Syntax.
Filter
The filter starts with an operator symbol immediately followed by the value (if any):
>1000 ← operator ">", value "1000"
=active ← operator "=", value "active"
in:a,b,c ← operator "in:", value "a,b,c"
is:null ← operator "is:null", no value (the value part is empty)
^^start ← NOT valid — "^^" is not a registered operator
The FilterParser tries registered operators longest-first to avoid ambiguity between, for example, !~* and !~.
For the complete list of operators with their symbols, value formats, and SQL output, see Operators Reference.
Composite Expressions
Multiple conditions can be combined in a single string using:
| Operator | Meaning | Precedence |
|---|---|---|
&& |
AND | Higher |
|| |
OR | Lower |
( ) |
Grouping | Overrides default precedence |
The grammar follows standard boolean precedence: && binds tighter than ||.
A && B || C is parsed as (A && B) || C
A || B && C is parsed as A || (B && C)
(A || B) && C grouping overrides, result: (A || B) && C
Examples
Simple AND:
status?=active&&total?>1000
Produces: status = :p1 AND total > :p2
Simple OR:
category?=electronics||category?=hardware
Produces: category = :p1 OR category = :p2
AND with nested OR:
status?=active&&(type?=person||tax_id?^78)
Produces: status = :p1 AND (type = :p2 OR tax_id LIKE :p3)
OR with nested AND:
category?=software||(category?=hardware&&price?>200)
Produces: category = :p1 OR (category = :p2 AND price > :p3)
Parser Rules
&&and||are only split at parenthesis depth 0, so function calls likeSUM(amount)inside paths are not split.- Whitespace around
&&and||is trimmed. - A fully parenthesized expression like
(A&&B)is unwrapped and parsed recursively.
Multiple Expressions as an Array
Instead of combining with &&/|| in a string, you can pass an array of expressions to where() or andWhere(). Each element is ANDed together:
$qb->where(['status?=active', 'total?>1000']);
// Equivalent to: status?=active&&total?>1000
This is useful when expressions are generated dynamically:
$filters = [];
if ($status) {
$filters[] = 'status?=' . $status;
}
if ($minTotal) {
$filters[] = 'total?>=' . $minTotal;
}
$qb->where($filters);
The ?E Literal-Expression Marker
By default the value part of a filter is treated as a literal — it becomes a bound parameter:
price?>1000 → price > :param with :param = '1000'
To reference another column or expression (not a literal value), replace ? with ?E:
id?E!=other_alias.id
This tells the parser that the value is an SQL identifier or expression, not a bound parameter. The value is sanitized as an identifier and inserted directly into the SQL — no parameter binding.
Use this sparingly and only for trusted, controlled inputs, since the sanitizer strips characters but does not provide the same guarantees as prepared statement binding.
Example from the test suite — simulating a self-join:
$qb->where([
'products[alias:p1]__invoice_details[on:id=product_id,alias:id1]__invoice_id?isnot:null',
'products[alias:p1]__invoice_details[...alias:id2]__products[...alias:p2]__id?E!=p1.id',
]);
The second condition generates p2.id != p1.id (column reference, not a literal).
How Expressions Are Parsed
The entry point is CompositeExpressionParser::parse(string $expression).
- Trim whitespace.
- Split on
||at depth 0 → if more than one part, build an OR composite. - For each part, split on
&&at depth 0 → if more than one part, build an AND composite. - For each atom, if wrapped in
(…)unwrap and recurse; otherwise, callExpressionParser::parse().
ExpressionParser::parse():
- Look for
?Emarker → setliteral = false, replace?Ewith?. - Split on the first
?→pathExpressionandfilterExpression. - Call
PathParser::parse(pathExpression)→Path. - Call
FilterParser::parse(filterExpression)→Filter. - Return
new Condition(path, filter, literal).
FilterParser::parse():
- Retrieve operators sorted longest-first from
OperatorManager. - Try each symbol as a prefix of
filterExpression. - On match: extract value, create
Filter, callvalidate(), return. - No match: throw
InvalidArgumentException.
Validation at Parse Time
The Filter::validate() method checks the value against the operator’s pattern field (if defined):
- Operators whose symbol ends in
:(e.g.like:,in:,between:) always require a non-empty value. - Specific patterns: dates must match
YYYYMMDDorYYMMDD, bitwise values must be integers, list values must match the allowed character set. - NULL operators (
is:null,isnot:null,is:empty,isnot:empty) require an empty value — the operator symbol itself is the full expression.
Invalid expressions throw InvalidArgumentException immediately, before any SQL is generated.