$filter
The $filter query option restricts the set of returned entities. The filter expression is parsed into an AST and translated to SQL WHERE clauses by the resolver.
Syntax
GET /odata/Products?$filter=price gt 10
GET /odata/Products?$filter=name eq 'Widget'Comparison operators
| Operator | Meaning | Example |
|---|---|---|
eq | Equal | $filter=name eq 'Widget' |
ne | Not equal | $filter=name ne 'Widget' |
gt | Greater than | $filter=price gt 10 |
ge | Greater than or equal | $filter=price ge 10 |
lt | Less than | $filter=price lt 100 |
le | Less than or equal | $filter=price le 100 |
in | In list | $filter=status in ('active','pending') |
Logical operators
| Operator | Meaning | Example |
|---|---|---|
and | Logical AND | $filter=price gt 10 and active eq true |
or | Logical OR | $filter=origin eq 'lhr' or origin eq 'jfk' |
not | Logical NOT | $filter=not contains(name,'test') |
String functions
| Function | SQL mapping | Example |
|---|---|---|
contains(prop, 'val') | LIKE '%val%' | $filter=contains(name,'Widget') |
startswith(prop, 'val') | LIKE 'val%' | $filter=startswith(name,'Wid') |
endswith(prop, 'val') | LIKE '%val' | $filter=endswith(name,'get') |
Case-insensitive matching
tolower() and toupper() may wrap either argument of the three functions above. The wrapper is stripped before the LIKE is built, because the default MySQL and SQLite collations already match case-insensitively:
$filter=contains(tolower(name),'widget') → WHERE name LIKE '%widget%'On a case-sensitive collation this is a no-op rather than a fold — the comparison follows the column's collation, not the function. Outside these three functions, tolower()/toupper() are not translated.
Lambda operators
any and all filter a collection by a predicate on its related entities. Eloquent-backed sets only — see Coverage by resolver below.
GET /odata/Flights?$filter=passengers/any(p:p/name eq 'Alice')
GET /odata/Flights?$filter=passengers/all(p:p/checkedIn eq true)| Operator | Meaning | Eloquent translation |
|---|---|---|
any | at least one related entity matches | whereHas() |
all | every related entity matches | whereDoesntHave() with the negated predicate |
all is expressed as "no related entity fails the predicate", which is also how it treats an empty collection: a Flight with no passengers satisfies passengers/all(...).
Null handling
$filter=description eq null → WHERE description IS NULL
$filter=description ne null → WHERE description IS NOT NULLPrecedence and grouping
Use parentheses to control precedence:
$filter=(price gt 10 and price lt 100) or name eq 'Special'Without parentheses, and binds tighter than or.
Coverage by resolver
The parser accepts the full OData filter grammar. The two built-in translators implement a subset of it, and they do not implement the same subset:
| Construct | FilterToEloquent (discoverModel) | FilterToQuery (SQL / AbstractEntitySet) |
|---|---|---|
eq ne gt ge lt le | yes | yes |
and or not | yes | yes |
in | yes | yes |
null comparison | yes | yes |
contains startswith endswith | yes | yes |
tolower toupper (wrapping the above) | yes | yes |
any / all | yes | no |
Arithmetic (add sub mul div mod) | no | no |
Date/time functions (year(), month(), …) | no | no |
has (enum flags) | no | no |
| Geo-spatial functions | no | no |
Custom resolvers receive the parsed AST and may implement any of it — see Custom Resolvers.
An untranslated construct is dropped, not rejected
A filter the translator does not implement currently contributes no condition at all. The request is accepted, answers 200 OK, and returns the collection as if the predicate had not been sent:
GET /odata/Orders?$filter=year(created_at) eq 2026 → every order, not just 2026'sNever rely on $filter to scope a read to what a caller may see. Scope the entity set itself — a custom entity set whose query() applies the restriction, or a read authorizer — and treat $filter as what the client asks for, never as what the server enforces.
This is tracked as a defect: the translators should answer 501 Not Implemented for constructs they cannot translate.