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:
| Case | Stored variants | Active variants |
|---|---|---|
| Sparse | 24 | 16 |
| Moderate | 250 | 125 |
| Fanout | 2,500 | 1,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:
| Loader | Queries | Catalogue models | Variant models |
|---|---|---|---|
| Lazy relationship models | 26 | 25 | 1,250 |
| Eager relationship models | 2 | 25 | 1,250 |
One relationship count() per parent | 26 | 25 | 0 |
Constrained withCount | 1 | 25 | 0 |
| Grouped query-builder projection | 1 | 0 | 0 |
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_orderIt 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.