Skip to content

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/odata owns 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 the AbstractUi5App extends ODataService line.

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

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

HelperWhen to use itReads
discoverModel(Model::class)The Eloquent model is the entity; you want unscoped reads with Edm types inferred from $castsAuto-derived from the model
discoverCustomEntitySet(EntitySet::class)You need scoped reads (per-org, per-actor), custom shape, derived columns, or a query-builder-based sourceDefined 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:

php
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 a ResolverMapBuilder and binds entity sets to data sources. Ordinary service bindings belong in your module's ServiceProvider, 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:

php
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 to laravelui5/odata's controller, with Core's middleware stack applied.
  • Middleware: webFetchCsrfTokenResolveODataEndpointEnsureODataAuthenticated. The CSRF middleware implements the SAP Fiori X-CSRF-Token: Fetch handshake (HEAD or GET against the OData root with the header returns a new token in the response header). Auth runs through Core's EnsureODataAuthenticated, which reads the resolved service and gates through the host's installed guards.
  • Endpoint resolution: Ui5ODataServiceRegistry parses {namespace}@{version} from the request path, looks up the artifact in Core's registry, and binds the resolved ODataServiceInterface into the container so downstream middleware can read it.
  • Manifest injection: ManifestController automatically merges sap.app.dataSources.mainService + an unnamed default model bound to it into your served manifest.json — pointing at /odata/{your-namespace}@{your-version}/. Any source-declared models in your webapp/manifest.json survive 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 Ui5CoreServiceProvider sets odata.register_routes = false during service-provider registration. This disables laravelui5/odata's default auto-route registration so Core can install the routes with its own middleware stack. Do not override this back to true — 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:

bash
# 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}/MyEntitySet

The 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() over discoverModel() 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 without odata:cache (on a cached service only bindFunctions() 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.