Index

Eager Loading Is Not a Query Shape

A catalogue endpoint returns 25 names and the number of active variants behind each one. The first implementation runs 26 queries. Adding with('activeVariants') reduces that to two.

The N+1 defect is gone. The new implementation still hydrates 1,250 Variant models, including a synthetic 4 KiB metadata column, to produce 25 integers.

Eager loading solved the repeated-query shape. It did not ask what the response needed. That is the larger decision: queries, selected rows and columns, hydrated models, serialization, and the database plan all belong to one read path.

Start with the response, not the relationship

The LAR-04 fixture gives the same catalogue data two contracts. The summary returns an identifier, name, and active-variant count. The detail response returns those identifiers and names plus every active variant’s identifier, SKU, price, and display order.

Those contracts create different reasons to visit the relationship. The detail reader needs variant records. The summary reader needs one fact derived from them.

Before comparing speed, the fixture requires every loader for one contract and cardinality to produce the same normalized JSON hash. Empty catalogues must remain present. Variant inclusion and ordering must match. The detail loaders must omit the metadata column from the response.

This prevents a wonderfully fast query from winning by answering a smaller question.

Query count, rows, and models are different counts

The fixture seeds 25 catalogues three ways:

CaseStored variantsActive variants
Sparse2416
Moderate250125
Fanout2,5001,250

Every variant carries the unused 4 KiB metadata field. The field is deliberately artificial. It makes full-row selection visible without pretending to model Brian’s schema.

On the fanout summary, five loaders produce the same 1,492-byte response:

LoaderQueriesCatalogue modelsVariant models
Lazy relationship models26251,250
Eager relationship models2251,250
One relationship count() per parent26250
Constrained withCount1250
Grouped query-builder projection100

The first and third rows are both N+1 shapes, but they spend work differently. One retrieves complete models; the other asks the database for 25 scalar counts. “26 queries” is useful evidence, but not a complete description of either path.

The two-query eager loader removes repetition while preserving all 1,250 variant models. That is appropriate when the response needs those models. It is wasted hydration when the response needs only their count.

Laravel’s relationship documentation describes withCount for exactly this distinction: add a related count without loading the related models. The fixture’s one query contains a correlated aggregate. The grouped projection uses a left join and group by. Both return the same summary, but their SQL and plans differ. Query count alone cannot choose between them.

Eager loading fits the detail response

Change the contract so every active variant must be returned. Now an aggregate cannot substitute for the relationship.

The lazy detail loader issues one parent query and 25 relationship queries. The eager loader issues one parent query and one where in relationship query. Both hydrate 25 catalogues and 1,250 active variants in the fanout case. Both produce the same 94,492-byte response.

Here eager loading fits the reader. We can still improve its selection. The first eager variant selects every stored variant column, including metadata. The constrained variant selects only:

id, catalogue_id, sku, price_minor, active, display_order

It keeps catalogue_id even though the response does not return it. Eloquent needs that foreign key to match each child back to its parent. The fixture removes the key in an isolated control: 125 moderate variants are retrieved, but none attach to a catalogue and the detail response becomes incomplete.

“Select only what you return” is therefore too crude. Select what the response needs plus the keys the loader needs to assemble it.

The fanout run makes the unused column visible in process observations. The all-column eager loader has a 32 MiB retained peak; the selected-column loader has a 22 MiB peak. Their local median wall times are 25.5 ms and 18.6 ms. Those numbers describe this pinned synthetic process, not a promised percentage. Allocator granularity, SQLite, filesystem state, casts, accessors, and real payloads can change the result.

An aggregate still needs a plan

withCount is concise and keeps Eloquent catalogue models. The explicit summary projection hydrates no Eloquent models. In the fanout run their local median wall times are 6.0 ms and 1.9 ms, but that is not a reason to replace every aggregate with a join.

The fixture retains EXPLAIN QUERY PLAN output for each executed statement and the composite index on catalogue, active state, and display order. SQLite’s plan documentation calls this output an interactive debugging aid and warns that its format can change. A correlated subquery, join, filter, sort, or index can behave differently after the data distribution or database backend changes.

The useful comparison includes:

  • the exact response fields and ordering;
  • relationship cardinality and selectivity;
  • selected columns and hydrated models;
  • SQL, bindings, plan, and indexes;
  • pagination ownership; and
  • the maintenance cost of a separate read projection.

An ordinary Laravel query-builder result can be a projection. It does not become CQRS through lack of Eloquent models. A separate read model earns its name and cost when the reader’s fields, joins, ordering, pagination, or scale diverge enough from model navigation to justify independent ownership.

For a small stable summary, withCount may be the clearest answer. For a shared reporting shape with several aggregates and joins, a dedicated projection may be easier to reason about. Inspect both under the intended backend and data distribution.

Lazy-loading prevention guards one boundary

Laravel can prevent lazy loading. In the fixture, enabling the guard allows the parent query, then throws when either lazy-model loader first accesses activeVariants as a property.

That is a useful structural tripwire. It detects a relationship query appearing at property access. It does not reject an intentional $catalogue->activeVariants()->count() call, and it does not prove that an eager query selected sensible columns or loaded a bounded number of rows.

Laravel also lets an application log lazy-loading violations instead of throwing them. That choice belongs to the operating contract: a production exception may be unacceptable, while a sampled warning may be actionable. Whichever policy is chosen, the signal identifies where to inspect. It does not select the replacement query.

API resources offer another useful boundary. whenLoaded and whenCounted in Laravel’s resource documentation let serialization include a relationship or count only when the controller supplied it. This keeps the resource from quietly initiating database work. It still leaves the controller responsible for choosing the right shape.

Cardinality can reverse a reasonable choice

Lazy loading is not forbidden. One known parent with one bounded optional relationship may be clearer as an extra query than as a universal eager-load rule. The fixture uses 25 parents specifically to expose repetition.

Eager loading is a strong fit when a collection response actually returns the related records and the cardinality is bounded. Constrained columns keep the relationship honest. withCount and other aggregates fit scalar relationship facts when their plans remain acceptable. An explicit projection becomes credible as a stable reader moves away from the model graph.

Revisit the choice when response fields, filters, pagination, cardinality, selectivity, indexes, database version, backend, casts, accessors, or traffic distribution changes. Two queries stay two queries while the related set grows from 16 to 1,250 models. That constant count can conceal the change that matters.

The reproduction retains 27 loader profiles, 81 fresh-process trials, every query and SQLite plan, 20 rejected counterfactuals, 12 primary-source responses, six score-free decisions, and a clean export of commit b3a0ec988122ff17f13b79c333ec1e1ea038e0fc. It proves its two response contracts over its pinned SQLite data. It does not prove Brian’s query shape, cardinality, backend, index, latency, memory limit, or projection boundary.

Fix an N+1 query by choosing the shape the reader needs. Sometimes that is eager loading. Sometimes it is a count, a selected relationship, or a projection. The query count tells you where the conversation starts, not where it ends.