OData V4 Concepts
A minimal primer for PHP developers who have never worked with OData. This page defines the vocabulary. For the case for adopting OData over a hand-rolled API — and where it is the wrong call — see Why OData?.
What is OData?
OData (Open Data Protocol) is a REST-based protocol for querying and manipulating data. It defines a standard URL syntax for filtering, sorting, paging, and selecting data, plus a machine-readable schema format (CSDL) that describes the data model.
Read-only by design
laravelui5/odata answers queries and never writes. POST is accepted only for a $batch of GET requests; PUT, PATCH, DELETE and any other POST are refused with 400 method_not_allowed. Writes stay in your application, where validation and business rules belong. With LaravelUi5 Core, a write is a Ui5Action.
Core concepts
Entity Type
An entity type is a named structured type whose instances have an identity — a key. That is the whole definition. Nothing in it says table, and nothing in it says model.
The key is the one hard requirement the specification places on you. Every entity type reachable through an entity set must nominate one or more of its properties as the key, because the key is what makes a single instance addressable.
EntityType: Product
Key: id (Edm.Int64)
Properties:
id Edm.Int64
name Edm.String
price Edm.Decimal
active Edm.Boolean
URL: /odata/Products(42)Composite keys are supported. Declare several properties as the key and the URL carries all of them: /odata/Assignments(tenant_id=7,project_id=34).
Beyond the key, what an entity type abstracts is your decision, not the library's. In laravelui5/odata it can be backed by
- an Eloquent model, discovered with
discoverModel(); - a SQL view, join, or aggregate, declared with
AbstractEntitySet; - an external REST API, wrapped in a custom resolver;
- a directory of Markdown files, a YAML catalog, an in-memory collection — anything you can iterate.
The engine asks a resolver for rows as associative arrays. A blog whose posts live as files on disk is a perfectly good entity type: key slug (Edm.String), properties title, published_at, body. Nothing about OData requires a database, and nothing in this library adds that requirement.
The further from SQL, the more work is yours
For Eloquent- and SQL-backed sets the engine applies $filter, $orderby, $top, and $skip on your behalf. A custom resolver receives the same parsed query plan but applies as much of it as its source can support — see Custom Resolvers.
Keys must be unique and stable within the set. A synthetic running counter satisfies the protocol, but it gives clients nothing durable to select on, so when the key is synthetic, expose the meaningful identifier as a property of its own.
Entity Set
An entity set is a named, addressable collection of instances of one entity type. It is what a URL resolves to, and what $filter, $orderby, and paging operate on.
EntitySet: Products → EntityType: Product
URL: /odata/ProductsEvery entity set is bound to a resolver. The entity type describes the shape, the entity set names the collection, and the resolver produces the rows.
Property
A structural property is a named, typed value on an entity type or complex type. Properties are what $select projects, what $filter and $orderby address, and what $compute extends.
Properties are declared, not inferred per request. The EDM is compiled ahead of time (php artisan odata:cache) and published at $metadata, so a client knows the full shape before it sends its first query. Each property carries a name, a type, whether it is nullable, and optional facets such as length, precision, and scale.
A property does not have to be a stored column. A SQL expression, an aggregate, a value computed in a custom resolver, and a plain table column are indistinguishable to the client: it sees a typed name in $metadata. Model the surface you want to expose, not the storage you happen to have.
Which types a property may take is covered in The type system.
Navigation Property
A navigation property points at entities rather than at values. Clients traverse it with $expand, or by URL path.
EntityType: Flight
NavigationProperty: passengers → Collection(Passenger)
EntityType: Passenger
NavigationProperty: flight → FlightThere are two ways to get one.
From a real relation. discoverModel() reads the Eloquent relationships off a model and turns them into navigation properties. $expand then eager-loads them, so an $expand over a thousand parents is a second query, not a thousand.
As a virtual navigation property. A resolver may declare itself expandable on entity types it has no relation to. Implement VirtualExpandResolverInterface alongside CustomEntitySetInterface, name the parent types in expandsOn(), and the navigation property appears on them — with no foreign key, no relation, and no table behind it.
final class Kpis implements CustomEntitySetInterface, VirtualExpandResolverInterface
{
public function expandsOn(): array
{
return ['User' => 'kpis', 'Project' => 'kpis'];
}
public function resolveExpand(array $parentRow, string $parentEntityType, ExpandItem $expand): array
{
// $parentRow holds the User or Project row; $expand carries $filter, $select, ...
return [['kpi_id' => 1, 'key' => 'hours', 'name' => 'Hours', 'value' => 42.0]];
}
}GET /odata/Users(11)?$expand=kpis($filter=date eq 2024-01-15)
GET /odata/Projects(34)?$expand=kpisThat is how one KPI set computed across half a dozen tables hangs off both User and Project without either model knowing it exists. The filter inside the expand doubles as the parameter channel: an entity type may declare properties — date, project_id — that exist to carry context into resolveExpand() rather than to be returned.
Use a virtual navigation property for what is genuinely computed: metrics, aggregates, cross-model projections. When the children are real records that merely need filtering or scoping, give them an entity set of their own instead. The engine can filter, page, and cache an entity set; it cannot do any of that for rows a resolver assembles by hand.
Function
A function is a named, side-effect-free operation that returns a value. Functions can accept parameters.
Function: GetFlightCount() → Edm.Int32
Function: GetFlightsByOrigin(origin: Edm.String) → Edm.Int32Singleton
A singleton is a named single entity instance (not a collection). It is used for application settings or the current user.
The type system
Primitive types
Every property resolves to one of the Edm primitive types. All of them are available in columns() on an AbstractEntitySet and in Property declarations, as cases of EdmPrimitiveType.
| OData type | PHP value | Typical column |
|---|---|---|
Edm.Binary | string (base64 on the wire) | BLOB |
Edm.Boolean | bool | BOOLEAN |
Edm.Byte | int (0–255) | TINYINT UNSIGNED |
Edm.Date | string (Y-m-d) | DATE |
Edm.DateTimeOffset | string (RFC 3339) | DATETIME, TIMESTAMP |
Edm.Decimal | string | DECIMAL |
Edm.Double | float | DOUBLE |
Edm.Duration | string (ISO 8601, P3DT4H) | INTERVAL, seconds |
Edm.Guid | string | UUID, CHAR(36) |
Edm.Int16 | int | SMALLINT |
Edm.Int32 | int | INTEGER |
Edm.Int64 | int | BIGINT |
Edm.SByte | int (−128–127) | TINYINT |
Edm.Single | float | FLOAT |
Edm.Stream | stream reference | media resource |
Edm.String | string | VARCHAR, TEXT |
Edm.TimeOfDay | string (H:i:s) | TIME |
Edm.Date, Edm.DateTimeOffset, and Edm.TimeOfDay are normalised on the way out: whatever the source hands over is parsed and emitted in the format the specification requires. Edm.Decimal stays a string because that is how PDO returns DECIMAL, and it is the only representation that preserves the precision the column was declared with.
Spatial types
The type system carries the full spatial family — Edm.Geography and Edm.Geometry plus their Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, and Collection variants. Geography is measured on the WGS84 globe, geometry on a flat plane, and the reference system travels in the SRID facet. They are declarable and they appear in $metadata; the engine performs no spatial conversion, so what your source produces is what the client receives.
Enum types
Declare an int-backed PHP enum as a column type and it is projected to an Edm.EnumType:
use LaravelUi5\OData\Edm\EdmPrimitiveType;
public function columns(): array
{
return [
'project_id' => EdmPrimitiveType::Int64,
'tier' => LicenseTier::class, // int-backed PHP enum
];
}The members are written into $metadata, and the stored integer is projected to its symbolic name in responses: the client reads "tier": "Platform", not "tier": 2.
Complex types
A complex type is a structured value without identity — no key, and not addressable on its own. Use it for values that travel with their parent: an address, a monetary amount, a coordinate pair.
Type definitions
A type definition is a named alias for a primitive type, optionally with its facets fixed — a Weight over Edm.Decimal with precision 8 and scale 3. Declaring it once puts the meaning in the schema instead of repeating it on every property that uses it.
Facets and nullability
Facets qualify a type: nullable (true unless you say otherwise), maxLength, precision, scale, unicode, and srid for spatial types. They are published in $metadata, which is how a client can render a three-decimal numeric input, marked mandatory, without anyone writing that rule twice.
Query options
OData defines system query options that clients append to the URL:
| Option | Purpose | Example |
|---|---|---|
$filter | Filter rows | $filter=price gt 10 |
$select | Choose columns | $select=name,price |
$expand | Include related data | $expand=passengers |
$orderby | Sort results | $orderby=name asc |
$top | Limit result count | $top=10 |
$skip | Skip rows (offset) | $skip=20 |
$count | Include total count | $count=true |
$search | Free-text search | $search=widget |
$compute | Add computed columns | $compute=price mul 1.1 as taxed |
Two more are accepted without being acted on: $format and $skiptoken. Any other $-prefixed option is rejected as 400 Unknown system query option — except $apply, which answers 501 Not Implemented, since aggregation is a recognised OData feature this engine does not serve.
Metadata
Every OData service exposes a machine-readable schema at $metadata:
GET /odata/$metadataThis returns CSDL XML describing all entity types, properties, relationships, and functions. A UI5 app on an OData V4 model reads this metadata for types, labels and annotations, and SAP Fiori Elements generates whole forms, tables and filters from it.
Service document
The service root returns a JSON listing of all available entity sets:
GET /odata{
"@odata.context": "http://localhost/odata/$metadata",
"value": [
{"name": "Products", "kind": "EntitySet", "url": "Products"}
]
}