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\Contracts\Container\PrimitiveTypeEnum;
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'   => PrimitiveTypeEnum::Int64,
            'gzl'          => PrimitiveTypeEnum::String,
            'customer'     => PrimitiveTypeEnum::String,
            'hours_posted' => PrimitiveTypeEnum::Double,
        ];
    }

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

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

What AbstractEntitySet provides

  • columns() (abstract) -- declare columns as 'name' => PrimitiveTypeEnum::Type. 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() (abstract) -- return a fresh Query\Builder for the data source. Called on each request; OData system query options are applied on top.
  • 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. The same SqlQueryInterface serves as the shared contract for Core artifact types (Report, AnalyticsSet, ValueHelp).

Available types

Every PrimitiveTypeEnum case can be used in columns():

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

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'  => PrimitiveTypeEnum::Int64,
        'project_id' => PrimitiveTypeEnum::Int64,
        'name'       => PrimitiveTypeEnum::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(PrimitiveTypeEnum::Int64));

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

SQL source patterns

The query() method returns any Query\Builder:

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

// Query Builder with joins
public function query(): 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(): Builder
{
    return DB::table('my_summary_view');
}

// Specific database connection
public function query(): 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 manual entity type

Extend SqlEntitySetResolver and implement CustomEntitySetInterface + EntitySetSourceInterface directly when you need navigation properties, annotations, or other EDM features that columns() doesn't cover:

php
readonly class BillableProjects implements CustomEntitySetInterface, EntitySetSourceInterface
{
    public function entitySetName(): string { return 'BillableProjects'; }

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

    public function entityType(string $namespace): EntityTypeInterface
    {
        // Full EDM construction with nav props, annotations, etc.
        $keyProp = new Property('project_id', new PrimitiveType(PrimitiveTypeEnum::Int64));

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

Wrap it in a SqlEntitySetResolver at binding time to get automatic $filter, $top, etc. handling:

php
$builder->bindEntitySet(
    $container->getEntitySet('BillableProjects'),
    new SqlEntitySetResolver($billableProjectsInstance),
);

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.

OData: MIT | Core: BSL 1.1 | SDK: Commercial License