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.
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)
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"
}#[ODataProperty] — property level
Override a property's OData name, type, or nullability:
use LaravelUi5\OData\Service\Discovery\Attributes\ODataProperty;
class Flight extends Model
{
#[ODataProperty(name: 'FlightCode', type: 'Edm.String')]
public $flight_number;
}#[ODataIgnore] — property or method level
Exclude a column or relationship from discovery:
use LaravelUi5\OData\Service\Discovery\Attributes\ODataIgnore;
class Flight extends Model
{
#[ODataIgnore]
public $internal_notes; // Not exposed as OData property
#[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 a column off the wire and out of the query surface, mark it #[ODataIgnore], 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.