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\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 anEdm.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 freshQuery\Builderfor 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 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, 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 case | EDM type |
|---|---|
EdmPrimitiveType::Binary | Edm.Binary |
EdmPrimitiveType::Boolean | Edm.Boolean |
EdmPrimitiveType::Byte | Edm.Byte |
EdmPrimitiveType::Date | Edm.Date |
EdmPrimitiveType::DateTimeOffset | Edm.DateTimeOffset |
EdmPrimitiveType::Decimal | Edm.Decimal |
EdmPrimitiveType::Double | Edm.Double |
EdmPrimitiveType::Duration | Edm.Duration |
EdmPrimitiveType::Guid | Edm.Guid |
EdmPrimitiveType::Int16 | Edm.Int16 |
EdmPrimitiveType::Int32 | Edm.Int32 |
EdmPrimitiveType::Int64 | Edm.Int64 |
EdmPrimitiveType::SByte | Edm.SByte |
EdmPrimitiveType::Single | Edm.Single |
EdmPrimitiveType::Stream | Edm.Stream |
EdmPrimitiveType::String | Edm.String |
EdmPrimitiveType::TimeOfDay | Edm.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:
public function columns(): array
{
return [
'project_id' => EdmPrimitiveType::Int64,
'tier' => LicenseTier::class, // int-backed PHP enum
];
}Composite keys
Override key() to declare composite keys:
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:
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 ^^^^^^^ systemRead them with get(), and scope the query:
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:
// 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.
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.
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.