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.
The package ships no helper for this: a Tier-3 resolver owns its filter semantics, and the useful ones differ too much per domain to generalise. What it does ship is the tree — a FilterExpression built from typed nodes you can walk.
Here is the smallest honest walk: it understands property eq literal, combined with and, and refuses everything else.
use LaravelUi5\OData\Exception\BadRequestException;
use LaravelUi5\OData\Protocol\Planning\Expression\BinaryExpression;
use LaravelUi5\OData\Protocol\Planning\Expression\BinaryOperator;
use LaravelUi5\OData\Protocol\Planning\Expression\FilterExpression;
use LaravelUi5\OData\Protocol\Planning\Expression\LiteralExpression;
use LaravelUi5\OData\Protocol\Planning\Expression\PropertyPathExpression;
/**
* @return array<string, mixed> column => value
*/
private function equalityParams(?FilterExpression $filter): array
{
if ($filter === null) {
return [];
}
if ($filter instanceof BinaryExpression && $filter->operator === BinaryOperator::And) {
return $this->equalityParams($filter->left) + $this->equalityParams($filter->right);
}
if ($filter instanceof BinaryExpression
&& $filter->operator === BinaryOperator::Eq
&& $filter->left instanceof PropertyPathExpression
&& $filter->right instanceof LiteralExpression
) {
$segments = $filter->left->segments;
$column = $segments[count($segments) - 1]->getName();
return [$column => $filter->right->value];
}
throw new BadRequestException(
'unsupported_filter',
'This entity set supports only `eq` comparisons combined with `and`.',
);
}Used in the resolver:
public function resolve(QueryPlanInterface $plan): \Generator
{
assert($plan instanceof EntitySetQueryPlan);
$params = $this->equalityParams($plan->filter);
foreach ($this->query($params['date'] ?? null, $params['user_id'] ?? null) as $row) {
yield (array) $row;
}
}Refuse what you do not apply
The throw is the load-bearing line. A resolver that ignores the parts of the filter it does not understand answers with more rows than the client asked for, and the client has no way to tell: the response is a 200 with a plausible body. Silently widening a result set is the one failure mode worth extra code to avoid — so handle a construct or refuse it, and never fall through.
A PropertyPathExpression carries resolved model objects, not strings, which is why the column name comes off the last segment's getName(). A path longer than one segment is a navigation hop; if your set cannot follow it, that is another case to refuse.
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.