Model Discovery
Model discovery automatically maps Eloquent models to OData entity types. Call $this->discoverModel() in your service's configure() method, and the library inspects the model's database table, column types, casts, and relationships.
Basic usage
protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
$this->discoverModel(Flight::class);
$this->discoverModel(Passenger::class);
return $builder->namespace($this->namespace());
}
// No registerBindings() needed -- discovered models are auto-bound.Each discoverModel() call:
- Inspects the model's table via
Schema::getColumns() - Maps each column to an OData primitive type
- Uses
$model->getKeyName()as the entity type key - Discovers Eloquent relationships and maps them to navigation properties
- Creates an entity set with a pluralized name
- Auto-registers an
EloquentBindingin the ResolverMap
Naming conventions
| Element | Convention | Example |
|---|---|---|
| Entity type name | Model short class name | Flight |
| Entity set name | Pluralized type name | Flights |
| Property name | Column name (snake_case) | flight_id |
Column type mapping
The library maps database column types to OData primitive types. When a model declares a cast for a column, the cast type takes precedence over the database type.
Database type mapping
| DB Type | OData Type |
|---|---|
string, varchar, char, text | Edm.String |
integer, int, tinyint, smallint, mediumint | Edm.Int32 |
bigint | Edm.Int64 |
float, double, real | Edm.Double |
decimal, numeric | Edm.Decimal |
boolean, bool | Edm.Boolean |
date | Edm.Date |
datetime, timestamp | Edm.DateTimeOffset |
time | Edm.TimeOfDay |
blob, binary | Edm.Binary |
json, jsonb | Edm.String |
uuid, guid | Edm.Guid |
| Unknown types | Edm.String (fallback) |
Cast type overrides
| Eloquent Cast | OData Type |
|---|---|
'integer', 'int' | Edm.Int32 |
'float', 'double' | Edm.Double |
'decimal' | Edm.Decimal |
'boolean', 'bool' | Edm.Boolean |
'date' | Edm.Date |
'datetime', 'timestamp' | Edm.DateTimeOffset |
'immutable_date' | Edm.Date |
'immutable_datetime' | Edm.DateTimeOffset |
'string' | Edm.String |
'array', 'json', 'collection', 'object' | Edm.String |
Cast format suffixes (e.g., 'datetime:Y-m-d H:i:s') are stripped before mapping. A cast the table does not list — an enum cast, or a custom cast class — is ignored, and the column falls back to its database type.
Both declaration idioms are read: the legacy protected $casts property and the protected function casts(): array method Laravel has scaffolded since 11.x. (Before 3.0.5 only the property was — discovery instantiated models without their constructor, which is where Eloquent merges casts() in, so a modern model was read as if it had no casts at all. The symptom to recognise, if you are on an older version: $table->boolean() surfacing as Edm.Int32, because both SQLite and MySQL store a boolean as tinyint(1).)
Enum casts are not projected as Edm.EnumType
An int-backed PHP enum cast on a discovered model does not become an Edm.EnumType: the column is typed from the database (an integer column stays Edm.Int32) and the wire value stays the backing integer.
The symbolic projection — enum members in $metadata, member names in responses — is available on custom entity sets, where columns() accepts an enum class-string. Use one when the enum semantics matter to the client, or override the property with #[ODataProperty(type: 'Edm.Int32')] and let the client map the values.
Type facets
Discovery also reads the facets of each column from the table schema and publishes them in $metadata. A client can then format and validate a value without a second declaration. UI5's sap.ui.model.odata.type.Decimal, for example, formats by Scale.
| Column | Facets in $metadata |
|---|---|
NOT NULL | Nullable="false" |
decimal(p,s), numeric(p,s) | Precision="p" Scale="s" |
decimal(p) | Precision="p" Scale="0" |
varchar(n), char(n), nvarchar(n), character varying(n) | MaxLength="n" |
nvarchar(max) | MaxLength="max" |
A decimal(19,6) column therefore appears as:
<Property Name="amount" Type="Edm.Decimal" Nullable="false" Precision="19" Scale="6"/>Rules:
- A key is never nullable. SQLite reports an integer primary key as nullable, and discovery ignores that report.
- A facet needs a type that can carry it.
MaxLengthgoes only onEdm.StringandPrecision/Scaleonly onEdm.Decimal. If a cast or#[ODataProperty(type:)]changes the type, the column's length or scale is not carried over. - A nullable column with nothing else to say gets no facets. Its
Propertyelement carries only name and type, and the spec default (Nullable="true") applies. #[ODataProperty(nullable:)]overrides the column, see below.- The facets survive
odata:cache. The cached schema announces the same facets as the one built on each request.
The facets come from the column type as the database reports it. SQLite keeps the declared type text, but Laravel's SQLite grammar writes decimal() as numeric and string() as varchar, both without arguments. A SQLite database created by migrations therefore yields Nullable but no Precision, Scale or MaxLength. MySQL, MariaDB, PostgreSQL and SQL Server report all of them.
Overriding facets
Some facets are not a fact of the column. A unit price stored as decimal(19,6) may have to be announced with four decimals, because UI5 formats and validates input by Scale. There are two ways to say so.
On one model, with #[ODataProperty] (see below):
#[ODataProperty(precision: 15, scale: 4)]
public ?string $unit_price {
get => $this->getAttribute('unit_price');
set(?string $value) { $this->setAttribute('unit_price', $value); }
}For the whole installation, with a resolver. Bind your own implementation of ColumnFacetResolverInterface. Discovery calls it for every column with the model class, the column name, the model's cast for that column and the facets taken from the schema, and uses the facets it returns:
use LaravelUi5\OData\Edm\Type\TypeFacets;
use LaravelUi5\OData\Service\Contracts\ColumnFacetResolverInterface;
final class PriceDecimals implements ColumnFacetResolverInterface
{
public function resolve(string $modelClass, string $column, ?string $cast, TypeFacets $facets): TypeFacets
{
return $cast === UnitPrice::class
? $facets->withScale(config('shop.price_decimals'))
: $facets;
}
}
// in a service provider
$this->app->bind(ColumnFacetResolverInterface::class, PriceDecimals::class);The default resolver returns the facets unchanged. There is one binding, not a chain: a new binding replaces the previous one. To keep another resolver's answer, decorate that resolver.
The order per column is: schema → resolver → attribute. The attribute applies to one model and wins. A key stays non-nullable whatever the layers say. Discovery refuses facets the type cannot carry and throws a LogicException while building the schema:
Scaleon anything butEdm.Decimal;Precisionon anything butEdm.Decimalor a temporal type;MaxLengthon anything butEdm.StringorEdm.Binary;- a
Scaleabove thePrecision.
The resolver runs when the schema is built
A cold service builds its schema on every request, so it sees the current value. A service cached with odata:cache keeps the value the resolver returned when the cache was written. When the fact behind it changes, run odata:cache again.
UI5's V4 types treat Nullable="false" and MaxLength as constraints. A control bound two-way to such a property validates input against them.
(Before 3.1.0 discovery emitted no facets at all: every decimal was a bare Edm.Decimal, and #[ODataProperty(nullable:)] was accepted but had no effect.)
Relationship discovery
The library discovers relationships by instantiating the model and calling its public methods. Methods that return an Eloquent Relation are mapped to navigation properties.
| Eloquent Relation | OData Navigation | isCollection |
|---|---|---|
HasMany | Collection | yes |
BelongsTo | Single-valued | no |
HasOne | Single-valued | no |
BelongsToMany | Collection | yes |
Skipped relationships (not mapped):
MorphMany,MorphOne,MorphTo,MorphToMany(polymorphic)HasManyThrough,HasOneThrough(through-relationships)
Why polymorphic relations stay out
All of them, also those with a fixed target (morphMany, morphOne, morphToMany, morphedByMany). A polymorphic join runs over a type column plus an id (commentable_type, commentable_id). A navigation property in $metadata can state its join only as pairs of properties, so it cannot express the type condition. The schema would describe half a relation, and with morphTo the target type itself changes from row to row. Ambiguity at the level of the contract works against what OData is for.
To serve such an edge, model it explicitly:
- a custom entity set that selects the rows for one type, e.g.
Commentsfiltered oncommentable_type = Post::class; - a virtual expand that attaches them to the parent as a navigation of your own;
- or a regular
hasMany/belongsToon a table that is not polymorphic.
Each of these puts into the schema exactly what the service does.
Navigation properties are only wired when both sides of the relationship are discovered. If you discover Flight but not Passenger, the passengers navigation property is not created.
Navigation property bindings on entity sets are automatically created for each discovered navigation property.
Attribute overrides
Four PHP attributes let you customize the auto-discovery behavior. These are not OData vocabulary annotations — they are configuration attributes consumed only by the discovery engine.
#[ODataEntity] — class level
Override the entity type name or entity set name:
use LaravelUi5\OData\Service\Discovery\Attributes\ODataEntity;
#[ODataEntity(name: 'Airplane', entitySet: 'Airplanes')]
class Flight extends Model
{
// Entity type will be "Airplane" instead of "Flight"
// Entity set will be "Airplanes" instead of "Flights"
}useHidden: true also leaves the model's $hidden columns and relations out of the entity type, so they are neither declared nor filterable (see the $hidden note below). It is off by default.
#[ODataProperty] — property level
Override a property's OData name, type, nullability, precision or scale:
use LaravelUi5\OData\Service\Discovery\Attributes\ODataProperty;
class Flight extends Model
{
#[ODataProperty(name: 'FlightCode', type: 'Edm.String', nullable: false)]
public ?string $flight_number {
get => $this->getAttribute('flight_number');
set(?string $value) { $this->setAttribute('flight_number', $value); }
}
}The attribute sits on a property with PHP 8.4 hooks that delegate to Eloquent's attribute bag. A plain public $flight_number; would hide the column from Eloquent: reads would return null and writes would be lost on save(). Write the set hook as a block: the short form set($value) => $this->setAttribute(…) assigns the returned model to the property and fails on $flight->flight_number = … (see Applying annotations). nullable:, precision: and scale: override what the column declares and what a facet resolver returns. Without them, the column decides (see Type facets).
#[ODataIgnore] — property or method level
Exclude a column or relationship from discovery:
use LaravelUi5\OData\Service\Discovery\Attributes\ODataIgnore;
class Flight extends Model
{
#[ODataIgnore] // not exposed as an OData property
public ?string $internal_notes {
get => $this->getAttribute('internal_notes');
set(?string $value) { $this->setAttribute('internal_notes', $value); }
}
#[ODataIgnore]
public function auditLog() // Not exposed as navigation property
{
return $this->hasMany(AuditLog::class);
}
}#[ODataNavigation] — method level
Override a navigation property's OData name:
use LaravelUi5\OData\Service\Discovery\Attributes\ODataNavigation;
class Flight extends Model
{
#[ODataNavigation(name: 'Travelers')]
public function passengers()
{
return $this->hasMany(Passenger::class);
}
}What is NOT discovered
- Eloquent accessors (computed attributes) — not backed by DB columns
- Guarded/fillable — irrelevant for a read-only service
$hidden is not a security boundary here
Discovery reads the table schema, not the model's serialization rules. A column listed in $hidden is a projection boundary, not an authorization one, and the two halves behave differently:
- The payload respects it. The Eloquent read path serializes rows through
toArray(), so a hidden column never leaves the server.$select=id,passwordsilently returns{"id": 1}. - The contract does not. Discovery reads the table schema, so the column is still declared in
$metadata— and a declared column is filterable and sortable.$filter=startswith( password,'$2y')reaches SQL and matches. You cannot read the value; you can interrogate it, and enough questions is equivalent to reading it.
So $hidden protects the answer, not the question. To keep hidden columns off the wire and out of the query surface, set #[ODataEntity(useHidden: true)] on the model: what $hidden names is then not declared either (the key excepted). For a single column, mark it #[ODataIgnore] on a hooked property. Or define the entity type explicitly with a custom entity set that names only the columns you intend to serve.
Coexistence with manual schema
You can mix discovered models with manually declared types in the same service:
protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
// Auto-discover these models
$this->discoverModel(Flight::class);
$this->discoverModel(Passenger::class);
// Manually declare a function
$countFunc = new EdmFunction(name: 'GetFlightCount', returnType: $int32);
$countImport = new FunctionImport('GetFlightCount', $countFunc);
return $builder
->namespace($this->namespace())
->addFunction($countFunc)
->addFunctionImport($countImport);
}
protected function bindFunctions(RuntimeSchemaBuilderInterface $builder): void
{
// Discovered models are auto-bound -- only bind the function:
$container = $builder->getEdmx()->getEntityContainer();
$builder->bindFunctionImport(
$container->getFunctionImport('GetFlightCount'),
new class implements FunctionResolverInterface {
public function resolve(QueryPlanInterface $plan): mixed
{
return Flight::count();
}
},
);
}If you register a binding for a discovered model in registerBindings(), the manual binding overrides the auto-registered one.