OData Service
Introduction
Every UI5 application in LaravelUi5 is simultaneously a backend OData v4 service. There is no separate "make this app an OData service" step — extending AbstractUi5App is the step. The OData endpoint is reachable at /odata/{namespace}@{version}/… for every UI5 app the framework knows about.
This page documents how to define what your OData service exposes — entity sets, types, function imports — and how the Laravel side wires up authentication, CSRF, and the manifest. The OData v4 protocol itself (Edm types, query operators, response shapes, $metadata format) is implemented by the laravelui5/odata library; this page is the Core-side integration recipe.
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.
📝 Two contracts, two packages, one seam.
laravelui5/odataowns the protocol. Core owns the integration shape: the base class, the extension hooks, the route group, the middleware stack, the manifest injection. Both packages keep their promises independently and they meet at theAbstractUi5App extends ODataServiceline.
The integration in one paragraph
When you generate an app with ui5:app, the generated {Name}App.php extends AbstractUi5App, which itself extends LaravelUi5\OData\ODataService. From that inheritance, your app inherits a configure() hook (and two more — registerBindings(), bindFunctions()) that lets you declare your OData schema. Core wires the HTTP plumbing: a route group at /odata/{namespace}@{version}/…, a middleware stack handling auth + CSRF + endpoint resolution, an automatic injection of dataSources.mainService into the served manifest.json. You write configure(); you get a working OData service.
Defining entity sets
Override configure() and reach for one of two helpers. Author it on the App leaf — the class ui5:app --refresh never rewrites — so your schema survives every re-import of the UI5 source (see the base/leaf pair):
use LaravelUi5\OData\Service\Contracts\EdmBuilderInterface;
class OffersApp extends OffersAppBase
{
public const NAMESPACE = 'com.acme.offers';
public const VERSION = '1.0.0';
// … resource namespaces, Laravel manifest, vendor …
protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
// Discover an entity set from an Eloquent model.
// Edm types are inferred from $casts; queries are unscoped.
$this->discoverModel(Offer::class);
// Or use a dedicated entity-set class — gives you control over
// shape, scoping (e.g. #[ScopedToOrgActors]), and projections.
$this->discoverCustomEntitySet(MyOffersEntitySet::class);
$this->discoverCustomEntitySet(MyCustomersEntitySet::class);
return $builder->namespace($this->namespace());
}
}Two ways to declare an entity set, two use cases:
| Helper | When to use it | Reads |
|---|---|---|
discoverModel(Model::class) | The Eloquent model is the entity; you want unscoped reads with Edm types inferred from $casts | Auto-derived from the model |
discoverCustomEntitySet(EntitySet::class) | You need scoped reads (per-org, per-actor), custom shape, derived columns, or a query-builder-based source | Defined explicitly in the entity-set class |
The $builder->namespace($this->namespace()) at the end ties the EDM schema namespace to your app's UI5 namespace — com.acme.offers in the example — so the $metadata document advertises the right schema name.
Manual resolver bindings (optional)
A binding tells the engine where an entity set's rows actually come from. discoverModel() and discoverCustomEntitySet() register theirs automatically, so most apps never touch this hook. Override it when a set is backed by something those two do not cover — a database view, a raw table, a source class that needs container-injected dependencies:
use LaravelUi5\OData\Service\Builder\ResolverMapBuilder;
protected function registerBindings(ResolverMapBuilder $map): void
{
$map->sql($set, 'v_offer_totals'); // a raw table or view
$map->sqlSource($set, OfferTotalsSource::class); // a container-resolved source
$map->custom($set, OfferResolver::class); // a full custom resolver
}📝 Note the argument. This is not a Laravel container hook — it does not take the application and you do not
$this->app->bind()here. It receives aResolverMapBuilderand binds entity sets to data sources. Ordinary service bindings belong in your module'sServiceProvider, as always.
The resulting map is serialized by odata:cache for the warm boot path, which is the other reason bindings are declared here rather than assembled ad hoc.
Function imports (optional)
OData supports addressable functions like /odata/.../MyFunction(p='x'). Declare them via bindFunctions(), which receives the runtime schema builder:
use LaravelUi5\OData\Service\Contracts\RuntimeSchemaBuilderInterface;
protected function bindFunctions(RuntimeSchemaBuilderInterface $builder): void
{
$builder->bindFunctionImport($import, new OfferTotalResolver());
$builder->bindSingleton($singleton, new CurrentUserResolver());
}Unlike registerBindings(), these are not cached — they run on every boot, cold and warm alike. Optional, and most apps don't need it; see the laravelui5/odata documentation for the declaration shapes.
What Core handles automatically
You don't need to wire any of this; it happens for every UI5 app:
- Route group at
/odata/{path?}— catch-all that routes tolaravelui5/odata's controller, with Core's middleware stack applied. - Middleware:
web→FetchCsrfToken→ResolveODataEndpoint→EnsureODataAuthenticated. The CSRF middleware implements the SAP FioriX-CSRF-Token: Fetchhandshake (HEAD or GET against the OData root with the header returns a new token in the response header). Auth runs through Core'sEnsureODataAuthenticated, which reads the resolved service and gates through the host's installed guards. - Endpoint resolution:
Ui5ODataServiceRegistryparses{namespace}@{version}from the request path, looks up the artifact in Core's registry, and binds the resolvedODataServiceInterfaceinto the container so downstream middleware can read it. - Manifest injection:
ManifestControllerautomatically mergessap.app.dataSources.mainService+ an unnamed default model bound to it into your servedmanifest.json— pointing at/odata/{your-namespace}@{your-version}/. Any source-declared models in yourwebapp/manifest.jsonsurvive the merge.
What laravelui5/odata handles
Everything past the integration line. The protocol implementation — Edm type system, query parser ($filter, $orderby, $top, $skip, $expand, $select), $metadata generation, response shapes, CSRF handshake mechanics, batch requests — is the library's contract. For its API reference, configuration options, and protocol-level documentation, see the laravelui5/odata documentation.
📝 One configuration knob worth knowing about. Core's
Ui5CoreServiceProvidersetsodata.register_routes = falseduring service-provider registration. This disableslaravelui5/odata's default auto-route registration so Core can install the routes with its own middleware stack. Do not override this back totrue— the result would be double-registered routes without Core's auth and CSRF handling.
Smoke-testing your OData service
After generating an app (or adding entity sets to an existing one), three URLs let you verify the service end-to-end:
# Service document (lists entity sets)
curl https://your-app.test/odata/{namespace}@{version}/
# $metadata (full EDM schema, XML)
curl https://your-app.test/odata/{namespace}@{version}/\$metadata
# An entity set (replace MyEntitySet with one of yours)
curl https://your-app.test/odata/{namespace}@{version}/MyEntitySetThe first response carries an @odata.context and the entity-set listing. The second is an XML EDM document declaring your namespace and types. The third returns the entity-set rows (subject to your auth — log in first if your guards require it).
Best practices
- Always set the schema namespace to your app's UI5 namespace (
$builder->namespace($this->namespace())) — keeps the EDM document name aligned with your routing identity. - Prefer
discoverCustomEntitySet()overdiscoverModel()when you need scoped reads (e.g. per-organization filtering) — the entity-set class is where the scoping lives. - Don't override
configure()to mutate global state — it runs whenever the schema is built withoutodata:cache(on a cached service onlybindFunctions()runs) and should be idempotent. - Don't bypass Core's middleware by registering OData routes yourself — the auth and CSRF pipeline is non-trivial to replicate.
- If your app has no OData entities, leave
configure()unimplemented. The default no-op produces a valid (empty) OData service — your $metadata document is minimal, but the endpoint still resolves.
Related Links
- Ui5App
laravelui5/odatadocumentation — protocol documentation, entity-set API referencelaravelui5/odataon GitHub — the MIT-licensed engine itself- Artifacts Overview