Skip to content

[2.x] perf: point endpoint eager loads back at their parent models - #4877

Merged
imorland merged 1 commit into
2.xfrom
im/inverse-relations-on-eager-loads
Aug 1, 2026
Merged

[2.x] perf: point endpoint eager loads back at their parent models#4877
imorland merged 1 commit into
2.xfrom
im/inverse-relations-on-eager-loads

Conversation

@imorland

@imorland imorland commented Aug 1, 2026

Copy link
Copy Markdown
Member

The problem

Relations pre-loaded by an endpoint eager load (eagerLoad(), eagerLoadWhenIncluded(), eagerLoadWhere()) arrive through loadMissing(), which wires nothing back to the models they were loaded for. The relationship buffer would set the declared ->inverse() — but it skips relations that are already loaded, so for pre-loaded relations nobody did.

Serializing an included post then re-fetched its discussion one row at a time for every visibility check (canEdit, canHide, canFlagPostPolicy::can()$post->discussion) — even though that discussion is the very model being listed, sitting in memory the whole time.

A pure-core reproduction (one eagerLoadWhenIncluded(['firstPost' => ['firstPost.user']]) extender — the exact shape sticky+geoip, mentions and likes all use) on a 10-discussion page:

10x (10 distinct bindings): select * from "discussions" where "discussions"."id" = ? limit 1

The flarum/testing N+1 detector fails the request on its own before the test's assertion even runs.

Why nobody noticed

flarum/likes carries a hand-written workaround for exactly this bug — a conditional block eager loading firstPost.discussion + firstPost.discussion.tags (and the lastPost pair), whose own comment says it exists "to avoid N+1s in DiscussionPolicy::can()". Any install running likes was masked; any leaner install paid one discussion fetch per included post. FoF/geoip's test suite surfaced it as a 30x (15 distinct) repeated-query warning.

The fix

After loadRelations() finishes its loadMissing() calls, setInverseRelations() points each loaded relation back at its parent — using the relationship's declared ->inverse() and falling back to the same class-name derivation EloquentBuffer::load() already uses for buffered loads. The parent is already in memory, so the wiring costs zero queries. Only the first segment of each eager-load path is touched (deeper segments belong to other parents), and isRelation() guards against setting a relation the related model doesn't have.

Likes' workaround is retired in the same change. The parent discussions it re-fetched (with their tags) are exactly what the inverse now points at, and the tags extension already eager loads tags on those parents unconditionally, so DiscussionPolicy::can()'s $discussion->tags read stays batch-loaded.

Measured (74-extension install, responses byte-identical on all compared endpoints)

request before after
/api/discussions?include=firstPost,lastPost 43 queries, 17.2ms SQL 39 queries, 12.8ms
/ index document 41 queries, 22.2ms SQL 39 queries, 10.2ms

The removed queries are likes' now-redundant batched re-fetches. On installs without likes, the win is the N+1 itself: one query per included post, gone.

Testing

  • New ListWithIncludedPostsQueryCountTest, 3 tests: buffer-path regression guard (include=firstPost, was already correct), the loadMissing-path case written RED first, and a serialization-equivalence guard (linkage unchanged, all posts still included)
  • 782 core integration + 396 unit tests pass; mentions (84), sticky (21), likes (22), subscriptions (21) suites pass; mentions detector warnings identical before/after (10 = 10)
  • tags' single failure is the documented pre-existing utf8-slug flake on this host — verified identical on a clean tree
  • PHPStan clean

Follow-up (separate): flarum/sticky still forces full rendered firstPost into every index document for a plain-text excerpt shown only on stickied discussions — that's the remaining ~110ms/index-view item from the discuss profiling.

Relations pre-loaded by an endpoint eager load arrive through
loadMissing(), which wires nothing back to the models they were loaded
for. The relationship buffer would set the declared inverse, but it
skips relations that are already loaded — so nothing did. Serializing
an included post then re-fetched its discussion one row at a time for
every visibility check (canEdit, canHide, canFlag), even though that
discussion is the very model being listed.

Installs running flarum/likes never saw this: likes carries a
hand-written workaround that eager loads firstPost.discussion and its
tags precisely "to avoid N+1s in DiscussionPolicy::can()". Any leaner
install paid one discussion fetch per included post — a pure-core
reproduction with a single eagerLoadWhenIncluded extender shows 10
identical single-row fetches on a 10-discussion page.

loadRelations() now points each loaded relation back at its parent,
using the relationship's declared inverse and falling back to the same
class-name derivation EloquentBuffer::load() already uses for buffered
loads. The parent is already in memory, so the wiring costs no queries.
Likes' workaround is retired in the same change: the parent discussions
it re-fetched (with their tags) are exactly what the inverse now points
at, and the tags extension already eager loads tags on those parents.

On a 74-extension install, the discussions index with firstPost and
lastPost included drops from 43 to 39 queries and the index document
from 41 to 39, with responses byte-identical — the removed queries are
likes' now-redundant batched re-fetches.
@imorland
imorland requested a review from a team as a code owner August 1, 2026 09:33
@imorland imorland changed the title perf: point endpoint eager loads back at their parent models [2.x] perf: point endpoint eager loads back at their parent models Aug 1, 2026
@imorland imorland added this to the 2.0.0-rc.6 milestone Aug 1, 2026
@imorland
imorland merged commit fa31702 into 2.x Aug 1, 2026
25 checks passed
@imorland
imorland deleted the im/inverse-relations-on-eager-loads branch August 1, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant