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.

For common filter patterns (contains, eq, le, ge), the ExtractsFilterValues trait provides helpers:

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

OData: MIT | Core: BSL 1.1 | SDK: Commercial License