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.
AbstractEntitySet (recommended)
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.
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 freshQuery\Builderfor the data source. Called on each request; OData system query options are applied on top.entityType()-- assembled automatically fromcolumns()+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$countare applied automatically at the SQL level. No customresolve()orcount()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 case | EDM type |
|---|---|
PrimitiveTypeEnum::Binary | Edm.Binary |
PrimitiveTypeEnum::Boolean | Edm.Boolean |
PrimitiveTypeEnum::Byte | Edm.Byte |
PrimitiveTypeEnum::Date | Edm.Date |
PrimitiveTypeEnum::DateTimeOffset | Edm.DateTimeOffset |
PrimitiveTypeEnum::Decimal | Edm.Decimal |
PrimitiveTypeEnum::Double | Edm.Double |
PrimitiveTypeEnum::Duration | Edm.Duration |
PrimitiveTypeEnum::Guid | Edm.Guid |
PrimitiveTypeEnum::Int16 | Edm.Int16 |
PrimitiveTypeEnum::Int32 | Edm.Int32 |
PrimitiveTypeEnum::Int64 | Edm.Int64 |
PrimitiveTypeEnum::SByte | Edm.SByte |
PrimitiveTypeEnum::Single | Edm.Single |
PrimitiveTypeEnum::String | Edm.String |
PrimitiveTypeEnum::TimeOfDay | Edm.TimeOfDay |
Composite keys
Override key() to declare composite keys:
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:
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:
// 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:
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:
$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.
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():
protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
{
$this->discoverCustomEntitySet(BillableProjects::class);
return $builder->namespace('my.namespace');
}This single call:
- Adds the entity type and entity set to the Edm
- Registers a
CustomBindingin the resolver map - 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.
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:
$this->discoverCustomEntitySet(Kpis::class);This automatically:
- Adds the
Kpientity type andKpisentity set - Adds a
kpiscollection navigation property toUserandProject - Adds navigation property bindings on the entity sets
- Registers the
CustomBindingfor 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.