Custom Resolvers
Resolvers are the data access layer of the OData engine. Every entity set is backed by a resolver that converts query plans into data. The library ships with EloquentEntitySetResolver and SqlEntitySetResolver for database-backed entity sets. For other data sources -- REST APIs, in-memory collections, file systems -- you implement your own resolver.
For SQL-backed custom entity sets (the most common case), see Custom Entity Sets which covers AbstractEntitySet and the three implementation tiers.
Resolver interfaces
EntitySetResolverInterface
Required for every entity set. Handles collection queries and count.
use LaravelUi5\OData\Service\Contracts\EntitySetResolverInterface;
use LaravelUi5\OData\Service\Contracts\QueryPlanInterface;
class MyResolver implements EntitySetResolverInterface
{
/**
* Yield entities one by one as associative arrays.
*
* @param QueryPlanInterface $plan At runtime: EntitySetQueryPlan
*/
public function resolve(QueryPlanInterface $plan): \Generator
{
yield ['id' => 1, 'name' => 'First'];
yield ['id' => 2, 'name' => 'Second'];
}
/**
* Return total count (ignoring $top/$skip).
*
* @param QueryPlanInterface $plan At runtime: EntitySetQueryPlan
*/
public function count(QueryPlanInterface $plan): int
{
return 2;
}
}EntityResolverInterface
Optional. Implement alongside EntitySetResolverInterface to support single-entity access (/EntitySet(key)).
use LaravelUi5\OData\Service\Contracts\EntityResolverInterface;
class MyResolver implements EntitySetResolverInterface, EntityResolverInterface
{
public function resolve(QueryPlanInterface $plan): \Generator { /* ... */ }
public function count(QueryPlanInterface $plan): int { /* ... */ }
/**
* Return one entity by key, or null if not found.
*
* @param QueryPlanInterface $plan At runtime: EntityQueryPlan
*/
public function resolveOne(QueryPlanInterface $plan): ?array
{
/** @var \LaravelUi5\OData\Protocol\Planning\EntityQueryPlan $plan */
$id = $plan->key->values['id']->value;
return ['id' => $id, 'name' => 'Found'];
}
}If your resolver does not implement EntityResolverInterface, requests to single entities will return HTTP 501 Not Implemented.
Reading the query plan
The $plan parameter is typed as QueryPlanInterface (a marker interface) to keep the contract layer free of protocol imports. At runtime, it is always one of:
EntitySetQueryPlan-- forresolve()andcount()EntityQueryPlan-- forresolveOne()
EntitySetQueryPlan properties
/** @var EntitySetQueryPlan $plan */
$plan->target; // EntitySetInterface -- the entity set being queried
$plan->filter; // ?FilterExpression -- parsed $filter AST (or null)
$plan->select; // SelectList -- parsed $select
$plan->expand; // ExpandList -- parsed $expand
$plan->orderBy; // OrderByList -- parsed $orderby
$plan->top; // ?int -- $top limit
$plan->skip; // ?int -- $skip offset
$plan->count; // bool -- whether $count=true was requested
$plan->search; // ?string -- $search term
$plan->compute; // list<ComputedProperty> -- $compute expressions
$plan->maxPageSize; // ?int -- server page size limitEntityQueryPlan properties
/** @var EntityQueryPlan $plan */
$plan->target; // EntitySetInterface
$plan->key; // KeyExpression -- parsed key values
$plan->select; // SelectList
$plan->expand; // ExpandListExample: REST API resolver
class ApiResolver implements EntitySetResolverInterface, EntityResolverInterface
{
public function __construct(private string $apiUrl) {}
public function resolve(QueryPlanInterface $plan): \Generator
{
/** @var EntitySetQueryPlan $plan */
$response = Http::get($this->apiUrl, [
'limit' => $plan->top,
'offset' => $plan->skip,
]);
foreach ($response->json('data') as $item) {
yield $item;
}
}
public function count(QueryPlanInterface $plan): int
{
return Http::get($this->apiUrl . '/count')->json('total');
}
public function resolveOne(QueryPlanInterface $plan): ?array
{
/** @var EntityQueryPlan $plan */
$id = $plan->key->values['id']->value;
$response = Http::get("{$this->apiUrl}/{$id}");
return $response->successful() ? $response->json() : null;
}
}Binding options
CustomEntitySetInterface (recommended)
For self-describing entity sets, use discoverCustomEntitySet() in configure(). Handles type registration, set registration, and binding in one call. See Custom Entity Sets for details.
Manual binding via registerBindings()
For entity sets where the type comes from discovery or manual builder calls but the data source is a SQL view or table:
protected function registerBindings(ResolverMapBuilder $map): void
{
$container = $map->getEdmx()->getEntityContainer();
$map->custom($container->getEntitySet('MySet'), MyResolver::class);
$map->sql($container->getEntitySet('ViewSet'), 'my_sql_view');
$map->sqlSource($container->getEntitySet('FilteredSet'), FilteredSource::class);
}Manual binding via bindFunctions()
For non-cacheable resolvers that need runtime state:
protected function bindFunctions(RuntimeSchemaBuilderInterface $builder): void
{
$container = $builder->getEdmx()->getEntityContainer();
$builder->bindEntitySet(
$container->getEntitySet('ExternalProducts'),
new ApiResolver('https://api.example.com/products'),
);
}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.
Filtering, paging, and ordering
Unlike Eloquent-backed entity sets (where the library applies $filter, $top, $skip, and $orderby automatically), custom resolvers are fully responsible for interpreting the query plan themselves. The library passes the parsed AST via the EntitySetQueryPlan but does not apply it on your behalf.
This means:
- If you ignore
$plan->filter, the full unfiltered dataset is returned. - If you ignore
$plan->top/$plan->skip, all rows are emitted and client-side paging has no effect. - If you ignore
$plan->orderBy, results come back in whatever order your data source produces.
The library does handle $select projection (picking which properties appear in the JSON response) and server-driven paging (maxPageSize) at the handler level. Everything else is up to the resolver.
Exception: If your custom entity set extends SqlEntitySetResolver (directly or via AbstractEntitySet), all query options are applied automatically at the SQL level. See Custom Entity Sets.
Strategies for applying filters
SQL-level -- if your data source is a raw SQL query, you can inject WHERE clauses derived from the filter AST. This is the most performant option but requires translating the OData expression tree to SQL yourself.
In-memory -- iterate over all rows and skip those that don't match. Simpler to implement but processes the full dataset on every request. For small to medium result sets this is perfectly acceptable.
For common filter patterns (contains, eq, le, ge), the ExtractsFilterValues trait provides helpers:
use App\OData\ExtractsFilterValues;
final class DistinctProjects implements CustomEntitySetInterface
{
use ExtractsFilterValues;
public function resolve(QueryPlanInterface $plan): \Generator
{
assert($plan instanceof EntitySetQueryPlan);
$params = $this->extractFilterParams($plan->filter);
$date = $params['date'] ?? null;
$userId = $params['user_id'] ?? null;
foreach ($this->query($date, $userId) as $row) {
yield (array) $row;
}
}
}Important notes
resolve()must return aGenerator. Useyieldto emit rows.- Each row must be an associative array with keys matching the declared property names.
- The engine handles JSON serialization,
@odata.context, server-driven pagination, and$selectprojection. Your resolver only needs to produce raw data. - Custom resolvers must handle
$filter,$top,$skip, and$orderbythemselves -- unless they extendSqlEntitySetResolverorAbstractEntitySet.