Skip to content

PHPLARA-225 Support relation aggregates on document models - #3560

Merged
GromNaN merged 6 commits into
mongodb:5.xfrom
GromNaN:phplara-225-with-aggregate
Aug 13, 2026
Merged

PHPLARA-225 Support relation aggregates on document models#3560
GromNaN merged 6 commits into
mongodb:5.xfrom
GromNaN:phplara-225-with-aggregate

Conversation

@GromNaN

@GromNaN GromNaN commented Jul 30, 2026

Copy link
Copy Markdown
Member

Adds support for withCount(), withExists(), withSum(), withAvg(), withMin() and withMax() on document models. loadCount(), loadExists() and loadAggregate() work as well, since Laravel funnels them through the same withAggregate() method.

Before this change, any of them failed with BadMethodCallException: This method is not supported by MongoDB. Try "toMql()" instead., because Illuminate\Database\Eloquent\Concerns\QueriesRelationships::withAggregate() builds a correlated subselect and calls toSql().

Resolves PHPLARA-225, PHPLARA-93 and PHPLARA-237. Reported as #3003, #2470, #2435, #1801 and #1339.

How it works

MongoDB has no correlated subquery, so the values cannot be selected with the parent documents as Eloquent does. The new MongoDB\Laravel\Helpers\QueriesRelationshipAggregates trait records the requested aggregates in withAggregate(), then computes them in eagerLoadRelations(), after the parent documents are read, and sets them as attributes.

The cost is one additional query per aggregate, whatever the number of parent documents:

  • HasOneOrMany and its morph variants are aggregated by the server with a $group on the foreign key.
  • BelongsTo, BelongsToMany and MorphToMany cannot be grouped server side (a single related document per parent, or parent keys stored in an array field), so the eager loading match() logic is reused and the values are folded in PHP.
  • EmbedsOne and EmbedsMany need no query at all, they are computed from the parent document.

exists is computed as a grouped count mapped to > 0 and stored as a real boolean, so no bool cast is added on the model.

Aliases follow Illuminate exactly, so withCount('books') gives books_count, withMax('items', 'amount') gives items_max_amount and withCount('books as total') gives total. Defaults with no related document are 0 for count, false for exists and null for the others.

Limitations

Cases that cannot be supported throw a LogicException rather than returning a wrong value:

  • orderBy() on an aggregate alias: the value does not exist server side, so a $sort on it would silently return unsorted results. A $lookup pipeline is required to sort on an aggregate.
  • MorphTo and other relation types outside the set supported by has().
  • Hybrid relations, where the related model is not stored in MongoDB.
  • Constraint closures on embedded relations (PHPORM-292).

cursor() and lazy() do not call eagerLoadRelations(), so the aliases are absent on those paths, as with any eager loading.

The Boost skill and its query builder reference are updated, since they documented these methods as unsupported.

Why not $lookup?

Computing the aggregates inside the parent query with a $lookup stage would avoid the extra queries, but it is not possible today, for five reasons.

The query builder cannot emit a pipeline for a regular get(). It only switches from find() to aggregate() when $groups or $aggregate is set (src/Query/Builder.php:303), and it has no $lookup support at all. Adding one would force every query carrying a withCount() through an aggregation pipeline, changing the execution plan of the parent query itself, not just of the aggregate.

$lookup does no BSON type coercion. Foreign keys are frequently stored as strings while _id is an ObjectId, to the point that the package documentation recommends casting them with 'author_id' => 'string'. Eloquent normalizes both sides when matching in PHP, $lookup does not, so the counts would silently be 0. That is precisely the failure mode this PR avoids by throwing on the cases it cannot compute correctly.

Server compatibility. A correlated $lookup (let + pipeline), which is what counting without materializing the related documents requires, needs MongoDB 5.0, while CI still covers 4.4. The localField / foreignField form works on 4.4 but builds the full array of related documents before $size, so an unbounded relation can hit the 16 MB document limit or the 100 MB per stage memory limit.

One pipeline generator per relation type. belongsToMany stores the keys in an array field, morphToMany in pivot subdocuments discriminated by a morph type, and embedsMany is already part of the parent document. That is four distinct pipeline builders to write and test, instead of reusing the eager loading match() logic that already handles those dictionaries.

Scope. PHPLARA-93 is the open spike about rewriting the query builder on top of aggregation pipelines. This PR delivers the feature now, at one extra query per aggregate whatever the number of parent documents, and $lookup remains the natural follow up: it is the only way to lift the orderBy() limitation on an aggregate alias.

withCount, withExists, withSum, withAvg, withMin and withMax now work on
MongoDB models, as well as loadCount, loadExists and loadAggregate.

MongoDB has no correlated subquery, so the values cannot be selected with the
parent documents as Eloquent does. They are computed with one additional query
per aggregate, after the parent documents are read, then set as attributes.
Embedded relations need no extra query at all. Unsupported cases (MorphTo,
hybrid relations, ordering by an aggregate alias) throw instead of silently
returning a wrong value.
Comment thread resources/boost/skills/laravel-mongodb/references/query-builder.md
Comment thread resources/boost/skills/laravel-mongodb/references/query-builder.md Outdated
Comment thread resources/boost/skills/laravel-mongodb/SKILL.md Outdated
Comment thread src/Helpers/QueriesRelationshipAggregates.php Outdated
Comment thread src/Helpers/QueriesRelationshipAggregates.php Outdated
- Extract private methods with early returns in hydrateAggregate for readability
- Clarify the exception message when the aggregate column is not a string
- Cover sum, avg and min on an embedded relation in the tests
- Remove implementation detail and extra query cost from the skill docs
- Move the relation aggregate example to the query-builder reference only
@GromNaN

GromNaN commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Discussion notes:

  • Add a note in the docs that an optimisation is to store the aggregated value in the main document. That would solve orderBy compatibility issue and prevent from doing a 2nd query to retrieve the aggregation results.
  • Not covering vector search features because they require aggregation pipelines.

Split the alias resolution and the per relation query preparation into
named private methods, with early returns instead of nested branches.
Drop the ticket link from the embedded constraint exception message.
The parent key is null for embedded relations, so the two branches of
the former elseif are independent and the wrapper method is not needed.
The default value is computed inside aggregateValues.
Reject a column name that starts with a dollar sign, as it would be
interpreted as a variable reference in the aggregation pipeline.
Throw a descriptive exception when a stored relation key is not a
scalar or a stringable object, instead of a PHP cast error.
@GromNaN

GromNaN commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

I applied the review feedback and added two hardening checks after a security review:

  • A column name that starts with "$" is rejected. The query builder prefixes the column with "$" to build a field path, so a value such as "$ROOT" would become the variable reference "$$ROOT" in the aggregation pipeline.
  • A stored relation key that is not a scalar, a Binary, or a stringable object now causes a descriptive InvalidArgumentException instead of a PHP cast error during hydration.

Both checks have tests. Ready for another look.

@GromNaN
GromNaN marked this pull request as ready for review August 12, 2026 15:39
@GromNaN
GromNaN requested a review from a team as a code owner August 12, 2026 15:39
@GromNaN
GromNaN requested review from paulinevos and a lite review from Copilot August 12, 2026 15:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds MongoDB-compatible support for Eloquent relation aggregates on document models by deferring aggregate computation until after parent documents are loaded, avoiding Eloquent’s correlated subselect + toSql() path that previously threw.

Changes:

  • Introduces MongoDB\Laravel\Helpers\QueriesRelationshipAggregates to record withAggregate() requests and hydrate aggregate attributes during eagerLoadRelations().
  • Adds comprehensive test coverage for supported relations/functions and explicit exceptions for unsupported scenarios (e.g., ordering by aggregate aliases, morphTo, hybrid relations).
  • Updates Boost skill documentation and eval expectations to reflect that relation aggregates are now supported and clarifies the server-side sorting limitation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/skills/laravel-mongodb/evals/evals.json Updates eval to reflect withCount() support and the “no orderBy on alias” limitation.
tests/HybridRelationsTest.php Adds regression test ensuring hybrid relation aggregates fail with clear exceptions.
tests/Eloquent/WithAggregateTest.php New test suite covering relation aggregates across relation types, defaults, and limitations.
src/Helpers/QueriesRelationshipAggregates.php Implements deferred relation aggregate hydration for MongoDB Eloquent builders.
src/Eloquent/Builder.php Mixes the new aggregates trait into the MongoDB Eloquent builder.
resources/boost/skills/laravel-mongodb/SKILL.md Updates skill guidance to reflect new support and limitations.
resources/boost/skills/laravel-mongodb/references/query-builder.md Documents supported relation aggregates, limitations, and $lookup alternative patterns.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread resources/boost/skills/laravel-mongodb/SKILL.md Outdated
Comment thread src/Helpers/QueriesRelationshipAggregates.php Outdated
The embedded constraint guard now rejects limit, offset and distinct
in addition to where clauses, as they would be silently ignored and
produce a wrong aggregate value. Remove the extra query cost note
from the skill, it was incorrect for embedded relations.
return;
}

if (! DocumentModel::isDocumentModel($relation->getRelated()) || $this->isAcrossConnections($relation)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note on the isAcrossConnections() condition: it is conservative. Both hydration paths query the connection of the related model (server side $group for HasOneOrMany, eager match in PHP for the others), so a relation between two MongoDB connections could probably work. We keep the restriction for now because cross-connection MongoDB relations are rare and the path is untested. Relaxing this guard later is not a breaking change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants