Skip to content

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.

php
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)).

php
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 — for resolve() and count()
  • EntityQueryPlan — for resolveOne()

EntitySetQueryPlan properties ​

php
/** @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 limit

EntityQueryPlan properties ​

php
/** @var EntityQueryPlan $plan */
$plan->target;     // EntitySetInterface
$plan->key;        // KeyExpression -- parsed key values
$plan->select;     // SelectList
$plan->expand;     // ExpandList

Example: REST API resolver ​

php
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 ​

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:

php
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:

php
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.

php
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:

php
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 a Generator. Use yield to 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 $select projection. Your resolver only needs to produce raw data.
  • Custom resolvers must handle $filter, $top, $skip, and $orderby themselves — unless they extend SqlEntitySetResolver or AbstractEntitySet.