Choosing a Resolver
Every entity set is backed by a resolver, and the choice is usually settled by one question: how many rows does this surface return?
That question, not elegance, decides — because the two built-in resolvers differ in what they do per row, and the difference compounds.
The rule
| The surface | Rows | Use | Why |
|---|---|---|---|
| A list, an export, an aggregate | large or unbounded | AbstractEntitySet — raw SQL | Streams database rows straight to the wire |
| A detail, an object-page header, a small related list | one, or a handful | discoverModel() — Eloquent | Relations come free; hydration cost is noise at this size |
| Anything not backed by SQL | any | a custom resolver | You own the query plan |
Why row count decides
The SQL resolver iterates $query->cursor() and passes each row to the serializer as a plain stdClass. Nothing is instantiated per row, and nothing accumulates — memory stays flat whether the result is ten rows or a hundred thousand.
The Eloquent resolver takes the same rows and builds a model from each: attribute assignment, cast evaluation, event plumbing, then toArray() on the way out. Per row that is real work, and it compounds.
What that costs, measured
180 rows, one entity set, SQLite on a development machine, 50 iterations. Small numbers in absolute terms — the point is the ratio, and the ratio is what scales with your row count.
| Path | Time | vs. streaming |
|---|---|---|
Raw stdClass streaming — custom set (SqlEntitySetResolver) | 0.82 ms | 1× |
Eloquent, cast-free, toArray() | 27.97 ms | ~34× |
Eloquent, enum + date casts, toArray() | 60.77 ms | ~74× |
| Eloquent, paged to 50 rows | 13.79 ms | ~60× per row |
Eloquent + eager $expand of a related set | 71.71 ms | 3 SQL queries — eager, not N+1 |
Two things fall out of it.
Casts roughly double the cost, and enum casts are the expensive part. If a model exists purely to back an OData read, leaving casts off it is measurable — though if you are hydrating enough rows for that to matter, the real signal is that the surface belongs on a custom set.
$expand is not the problem. It eager-loads (->with(...)), so a relation costs a second query, not one per row. Per-row hydration is what costs.
At one row, 74× a very small number is still a very small number, and you get $expand for free. At ten thousand rows it is the difference between a response and a timeout.
This is the library's reason to exist
A high-speed SQL engine that does not hydrate is what distinguishes this package from a generic model-backed API layer. Moving a hot list path onto discoverModel() because it reads more nicely trades away the thing you installed it for.
What each one gives up
discoverModel() gives you the whole model surface for free — every column typed from the schema, every Eloquent relationship as a navigation property, $expand eager-loaded, any/all lambdas in $filter. It costs hydration per row, and it exposes what the table has rather than what you chose to publish (see the $hidden warning).
AbstractEntitySet gives you the exact shape you declare: your SQL, your column names, your types, a key you choose. It costs the relationships — a raw-SQL set has no navigation properties, so no $expand, and no lambda filters. Joins go in the query instead.
A custom resolver gives you any source at all — an HTTP API, a file tree, an in-memory collection. It costs the query engine: the plan arrives parsed, and applying $filter, $orderby, $top and $skip is your code's job. See Custom Resolvers.
Working the decision
Start from the surface, not the model. "Products" is not one answer — a product list and a product detail are different surfaces with different row counts, and they may reasonably be two entity sets backed differently. A master/detail screen is the normal case for exactly that split.
Reach for $expand when N is small. A detail view pulling a header plus a few related rows is the case Eloquent was built for: one discoverModel() call and the relations are addressable.
Don't build an entity set for one number. A tile showing a count, or a card showing a small struct, does not need an EDM type, a key, and a filter surface. In the LaravelUi5 stack those artifacts carry their own data providers; standing alone, a function is the cheaper answer.
Measure with the real row count. Fifty rows in development hides what fifty thousand does in production.
The other levers
Resolver choice is the big one, but not the only one:
odata:cacheremoves schema discovery and database introspection from the request entirely. Ship the generatedEdm/directory; production should never build a schema.- Page size bounds every response.
pagination.maxis the ceiling a client cannot argue with. $selectis pushed down to SQL, so a narrow projection reads narrow rows — except when$computeis present, which needs the full row.- Streaming (on by default) keeps memory flat and starts the response before the last row is read. See
streaming.