Skip to content

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/Products

Every 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.

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 → Flight

There 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.

php
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]];
    }
}
http
GET /odata/Users(11)?$expand=kpis($filter=date eq 2024-01-15)
GET /odata/Projects(34)?$expand=kpis

That 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.Int32

Singleton

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 typePHP valueTypical column
Edm.Binarystring (base64 on the wire)BLOB
Edm.BooleanboolBOOLEAN
Edm.Byteint (0–255)TINYINT UNSIGNED
Edm.Datestring (Y-m-d)DATE
Edm.DateTimeOffsetstring (RFC 3339)DATETIME, TIMESTAMP
Edm.DecimalstringDECIMAL
Edm.DoublefloatDOUBLE
Edm.Durationstring (ISO 8601, P3DT4H)INTERVAL, seconds
Edm.GuidstringUUID, CHAR(36)
Edm.Int16intSMALLINT
Edm.Int32intINTEGER
Edm.Int64intBIGINT
Edm.SByteint (−128–127)TINYINT
Edm.SinglefloatFLOAT
Edm.Streamstream referencemedia resource
Edm.StringstringVARCHAR, TEXT
Edm.TimeOfDaystring (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:

php
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:

OptionPurposeExample
$filterFilter rows$filter=price gt 10
$selectChoose columns$select=name,price
$expandInclude related data$expand=passengers
$orderbySort results$orderby=name asc
$topLimit result count$top=10
$skipSkip rows (offset)$skip=20
$countInclude total count$count=true
$searchFree-text search$search=widget
$computeAdd 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/$metadata

This 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
json
{
  "@odata.context": "http://localhost/odata/$metadata",
  "value": [
    {"name": "Products", "kind": "EntitySet", "url": "Products"}
  ]
}