Skip to content

Custom Entity Sets ​

Custom entity sets are entity sets that don't map to a single Eloquent model. They colocate the entity type definition, set name, and query logic in one class via CustomEntitySetInterface.

For SQL-backed custom entity sets — the most common case — extend AbstractEntitySet. Declare your schema with columns() and key(), provide the SQL source via query(). The entity type is assembled automatically.

php
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use LaravelUi5\OData\Edm\EdmPrimitiveType;
use LaravelUi5\OData\Http\CustomQueryOptions;
use LaravelUi5\OData\Service\AbstractEntitySet;

final readonly class BillableProjects extends AbstractEntitySet
{
    private const SQL = <<<'SQL'
        SELECT
            p.id AS project_id, p.gzl, k.`name` AS customer,
            ROUND(SUM(CASE WHEN o.editable THEN o.hours ELSE 0 END), 2) AS hours_posted
        FROM projects p
        LEFT JOIN postings o ON p.id = o.project_id
        LEFT JOIN customers k ON p.customer_id = k.id
        WHERE p.internal IS FALSE
        GROUP BY p.id
        HAVING hours_posted > 0
        ORDER BY hours_posted DESC
        SQL;

    public function entitySetName(): string { return 'BillableProjects'; }

    public function key(): array { return ['project_id']; }

    public function columns(): array
    {
        return [
            'project_id'   => EdmPrimitiveType::Int64,
            'gzl'          => EdmPrimitiveType::String,
            'customer'     => EdmPrimitiveType::String,
            'hours_posted' => EdmPrimitiveType::Double,
        ];
    }

    public function query(CustomQueryOptions $options): Builder
    {
        return DB::query()->fromSub(self::SQL, 't');
    }
}

That's the entire class. Three imports (EdmPrimitiveType, CustomQueryOptions, Builder), no manual EDM construction, no Property, PrimitiveType, or EntityType objects.

What AbstractEntitySet provides ​

  • columns() (abstract) — declare columns as 'name' => EdmPrimitiveType::String, or as the class-string of an int-backed PHP enum for an Edm.EnumType. Type-safe, IDE-autocompletable, no string-to-type translation layer.
  • key() — defaults to the first column. Override for composite keys: ['tenant_id', 'project_id'].
  • query(CustomQueryOptions $options) (abstract) — return a fresh Query\Builder for the data source. Called on each request; OData system query options are applied on top. The argument carries the request's custom query options — ignore it if the set does not scope on them.
  • entityType() — assembled automatically from columns() + key() + entitySetName(). The entity type name is the singular form of the set name (e.g. BillableProjects -> BillableProject). Override for full control.
  • Inherited from SqlEntitySetResolver — $filter, $top, $skip, $orderby, $search, $select, and $count are applied automatically at the SQL level. No custom resolve() or count() needed.

Interface hierarchy ​

AbstractEntitySet implements SqlQueryInterface, which composes:

  • ColumnarSchemaInterface (Edm\Contracts\) — pure schema: columns() + key()
  • EntitySetSourceInterface (Service\Contracts\) — query source: query(): Builder

This makes AbstractEntitySet a self-describing SQL data source, and it is the interface's one consumer today. It exists as a separate contract so that other SQL-backed surfaces can share it — the SDK's reporting and analytics layer is built on that idea.

Available types ​

Every EdmPrimitiveType case can be used in columns():

Enum caseEDM type
EdmPrimitiveType::BinaryEdm.Binary
EdmPrimitiveType::BooleanEdm.Boolean
EdmPrimitiveType::ByteEdm.Byte
EdmPrimitiveType::DateEdm.Date
EdmPrimitiveType::DateTimeOffsetEdm.DateTimeOffset
EdmPrimitiveType::DecimalEdm.Decimal
EdmPrimitiveType::DoubleEdm.Double
EdmPrimitiveType::DurationEdm.Duration
EdmPrimitiveType::GuidEdm.Guid
EdmPrimitiveType::Int16Edm.Int16
EdmPrimitiveType::Int32Edm.Int32
EdmPrimitiveType::Int64Edm.Int64
EdmPrimitiveType::SByteEdm.SByte
EdmPrimitiveType::SingleEdm.Single
EdmPrimitiveType::StreamEdm.Stream
EdmPrimitiveType::StringEdm.String
EdmPrimitiveType::TimeOfDayEdm.TimeOfDay

The spatial cases (Geography*, Geometry*) are declarable here too, but the engine performs no spatial conversion — values pass through as the source produces them.

Int-backed PHP enums need no case of their own. Name the enum class and the column is projected as an Edm.EnumType, with the stored integer rendered as its symbolic member name in responses:

php
public function columns(): array
{
    return [
        'project_id' => EdmPrimitiveType::Int64,
        'tier'       => LicenseTier::class,   // int-backed PHP enum
    ];
}

Composite keys ​

Override key() to declare composite keys:

php
public function key(): array
{
    return ['tenant_id', 'project_id'];
}

public function columns(): array
{
    return [
        'tenant_id'  => EdmPrimitiveType::Int64,
        'project_id' => EdmPrimitiveType::Int64,
        'name'       => EdmPrimitiveType::String,
    ];
}

Custom entity type name ​

By default, the entity type name is derived by singularizing the entity set name (BillableProjects -> BillableProject). Override entityType() if you need a different name or additional features like navigation properties or annotations:

php
public function entityType(string $namespace): EntityTypeInterface
{
    // Full control -- build the EntityType manually
    $keyProp = new Property('id', new PrimitiveType(EdmPrimitiveType::Int64));

    return new EntityType(
        namespace: $namespace,
        name: 'MyCustomName',
        key: [$keyProp],
        declaredProperties: [$keyProp, /* ... */],
        declaredNavigationProperties: [/* ... */],
    );
}

Custom query options ​

query() receives the request's custom query options — the parameters on the URL that are neither system options ($-prefixed) nor parameter aliases (@-prefixed):

GET /odata/Projects?roleCode=customer&$filter=active eq true&$top=20
                    ^^^^^^^^^^^^^^^^^ custom          ^^^^^^^ system

Read them with get(), and scope the query:

php
public function query(CustomQueryOptions $options): Builder
{
    $query = DB::table('projects');

    if ($role = $options->get('roleCode')) {
        $query->where('role_code', $role);
    }

    return $query;
}

get(string $key, ?string $default = null) returns one option; all() returns the map.

This is the channel for scoping a set on something that is not a filter over its own columns — a role, a context, a mode the client selects. It is passed as data rather than read from the global request, which is what makes it correct under $batch: each inner request builds its own options, so nothing leaks from the outer envelope or from a neighbouring request.

Custom options are not part of the entity type, so they never appear in $metadata and cannot be used in $filter or $orderby. A set that ignores the argument simply never reads it.

A custom option is client input

It arrives from the URL, unvalidated. Treat it exactly as you would a request parameter in a controller: validate it, and never interpolate it into raw SQL. It is also not an authorization boundary — a client can send any value. Gate who may read the set with Read Authorization; use custom options only to choose between readings the caller is already entitled to.

SQL source patterns ​

The query() method returns any Query\Builder:

php
// Raw SQL string wrapped as derived table
public function query(CustomQueryOptions $options): Builder
{
    return DB::query()->fromSub($sql, 't');
}

// Query Builder with joins
public function query(CustomQueryOptions $options): Builder
{
    return DB::table('flights')
        ->join('passengers', 'flights.id', '=', 'passengers.flight_id')
        ->select('flights.origin', DB::raw('count(*) as total'))
        ->groupBy('flights.origin');
}

// Simple table or view name
public function query(CustomQueryOptions $options): Builder
{
    return DB::table('my_summary_view');
}

// Specific database connection
public function query(CustomQueryOptions $options): Builder
{
    return DB::connection('reporting')->table('reporting_view');
}

Three tiers of custom entity sets ​

Tier 1 — SQL-derived (AbstractEntitySet) ​

The recommended path for 90%+ of cases. Extend AbstractEntitySet, declare columns() + key(), provide the SQL. All query options handled automatically.

See the examples above.

Tier 2 — SQL-derived with a hand-built entity type ​

Still AbstractEntitySet, but you take over entityType(). Reach for this when the entity type needs something columns() cannot express: navigation properties, annotations, a complex type. Everything else stays as it is — resolve(), count() and the whole query-option pipeline are inherited, and the registration is the same one line.

php
use Illuminate\Support\Facades\DB;
use LaravelUi5\OData\Edm\Contracts\Type\EntityTypeInterface;
use LaravelUi5\OData\Edm\EdmPrimitiveType;
use LaravelUi5\OData\Edm\Property\Property;
use LaravelUi5\OData\Edm\Type\EntityType;
use LaravelUi5\OData\Edm\Type\PrimitiveType;
use LaravelUi5\OData\Service\AbstractEntitySet;

final readonly class BillableProjects extends AbstractEntitySet
{
    public function entitySetName(): string { return 'BillableProjects'; }

    public function columns(): array
    {
        return [
            'project_id' => EdmPrimitiveType::Int64,
            'customer'   => EdmPrimitiveType::String,
        ];
    }

    public function query(CustomQueryOptions $options): Builder
    {
        return DB::query()->fromSub(self::SQL, 't');
    }

    // Full EDM construction: nav props, annotations, anything columns() cannot carry.
    public function entityType(string $namespace): EntityTypeInterface
    {
        $keyProp = new Property('project_id', new PrimitiveType(EdmPrimitiveType::Int64));

        return new EntityType(
            namespace: $namespace,
            name: 'BillableProject',
            key: [$keyProp],
            declaredProperties: [
                $keyProp,
                new Property('customer', new PrimitiveType(EdmPrimitiveType::String)),
            ],
            declaredNavigationProperties: [/* ... */],
        );
    }
}

columns() stays even when you override entityType() — the query pipeline reads it to know which columns exist and what type each one is. The two must agree; the entity type is what the client sees, columns() is what the engine selects and coerces.

Tier 3 — Fully custom ​

Implement CustomEntitySetInterface directly when the data source is not SQL: auth context, PHP computation, external APIs, cross-model aggregation.

php
final class Reports implements CustomEntitySetInterface
{
    public function entitySetName(): string { return 'Reports'; }
    public function entityType(string $namespace): EntityTypeInterface { /* ... */ }

    public function resolve(QueryPlanInterface $plan): \Generator
    {
        foreach ($this->loadReportsForUser(auth()->user()) as $report) {
            yield $report;
        }
    }

    public function count(QueryPlanInterface $plan): int { /* ... */ }
}

Fully custom resolvers must interpret $plan->filter, $plan->top, $plan->skip, and $plan->orderBy themselves. See Custom Resolvers for strategies.

Registration ​

All tiers use the same one-line registration in configure():

php
protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
    $this->discoverCustomEntitySet(BillableProjects::class);

    return $builder->namespace('my.namespace');
}

This single call:

  1. Adds the entity type and entity set to the Edm
  2. Registers a CustomBinding in the resolver map
  3. Resolves the class from the Laravel container at runtime (DI supported)

Virtual expands ​

Implement VirtualExpandResolverInterface alongside CustomEntitySetInterface to make the entity set appear as a navigation property on discovered Eloquent models — without a real Eloquent relation.

php
final class Kpis implements CustomEntitySetInterface, VirtualExpandResolverInterface
{
    public function entitySetName(): string { return 'Kpis'; }
    public function entityType(string $namespace): EntityTypeInterface { /* ... */ }

    public function expandsOn(): array
    {
        return ['User' => 'kpis', 'Project' => 'kpis'];
    }

    public function resolveExpand(array $parentRow, string $parentEntityType, ExpandItem $expand): array
    {
        $userId = $parentRow['id'];
        return [
            ['kpi_id' => 1, 'name' => 'Hours', 'value' => 42.0],
        ];
    }

    public function resolve(QueryPlanInterface $plan): \Generator { yield from []; }
    public function count(QueryPlanInterface $plan): int { return 0; }
}

Registration is the same:

php
$this->discoverCustomEntitySet(Kpis::class);

This automatically:

  1. Adds the Kpi entity type and Kpis entity set
  2. Adds a kpis collection navigation property to User and Project
  3. Adds navigation property bindings on the entity sets
  4. Registers the CustomBinding for the resolver

The client queries it via $expand:

GET /odata/Users(11)?$expand=kpis($filter=date eq 2024-01-15)

Cache support ​

CustomBinding stores only the resolver class-string, so it is fully serializable. odata:cache persists the binding and odata:clear removes it, just like Eloquent and SQL bindings.