Skip to content

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 ​

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

  1. Inspects the model's table via Schema::getColumns()
  2. Maps each column to an OData primitive type
  3. Uses $model->getKeyName() as the entity type key
  4. Discovers Eloquent relationships and maps them to navigation properties
  5. Creates an entity set with a pluralized name
  6. Auto-registers an EloquentBinding in the ResolverMap

Naming conventions ​

ElementConventionExample
Entity type nameModel short class nameFlight
Entity set namePluralized type nameFlights
Property nameColumn 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 TypeOData Type
string, varchar, char, textEdm.String
integer, int, tinyint, smallint, mediumintEdm.Int32
bigintEdm.Int64
float, double, realEdm.Double
decimal, numericEdm.Decimal
boolean, boolEdm.Boolean
dateEdm.Date
datetime, timestampEdm.DateTimeOffset
timeEdm.TimeOfDay
blob, binaryEdm.Binary
json, jsonbEdm.String
uuid, guidEdm.Guid
Unknown typesEdm.String (fallback)

Cast type overrides ​

Eloquent CastOData 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 RelationOData NavigationisCollection
HasManyCollectionyes
BelongsToSingle-valuedno
HasOneSingle-valuedno
BelongsToManyCollectionyes

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:

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

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

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

php
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,password silently 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:

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