Skip to content

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 surfaceRowsUseWhy
A list, an export, an aggregatelarge or unboundedAbstractEntitySet — raw SQLStreams database rows straight to the wire
A detail, an object-page header, a small related listone, or a handfuldiscoverModel() — EloquentRelations come free; hydration cost is noise at this size
Anything not backed by SQLanya custom resolverYou 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.

PathTimevs. streaming
Raw stdClass streaming — custom set (SqlEntitySetResolver)0.82 ms1×
Eloquent, cast-free, toArray()27.97 ms~34×
Eloquent, enum + date casts, toArray()60.77 ms~74×
Eloquent, paged to 50 rows13.79 ms~60× per row
Eloquent + eager $expand of a related set71.71 ms3 SQL queries — eager, not N+1
Per-row cost of the three read paths, relative to streamingThree horizontal bars on a zero baseline. Raw stdClass streaming is 1 times, the reference. Eloquent without casts is about 34 times. Eloquent with enum and date casts is about 74 times. The table above carries the absolute milliseconds.PER-ROW COST, RELATIVE TO STREAMINGraw stdClass streaming1×the referenceEloquent, cast-free34×Eloquent, enum + date casts74×
180 rows, SQLite, 50 iterations. Streaming is the 0.82 ms baseline — the ratio is what scales with your row count.

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:cache removes schema discovery and database introspection from the request entirely. Ship the generated Edm/ directory; production should never build a schema.
  • Page size bounds every response. pagination.max is the ceiling a client cannot argue with.
  • $select is pushed down to SQL, so a narrow projection reads narrow rows — except when $compute is 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.