Skip to content

Commit 5e8d91e

Browse files
authored
PHPLARA-225 Support relation aggregates on document models (#3560)
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.
1 parent 23a1638 commit 5e8d91e

7 files changed

Lines changed: 788 additions & 38 deletions

File tree

resources/boost/skills/laravel-mongodb/SKILL.md

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ metadata:
88

99
# Laravel MongoDB
1010

11-
Implementation skill for `mongodb/laravel-mongodb`. Exists to prevent the common mistakes: auto-increment IDs, `withCount()`, `toSql()`, SQL `JOIN`, `distinct()->get()` expecting scalar arrays, and `belongsTo()` over native ObjectId FKs.
11+
Implementation skill for `mongodb/laravel-mongodb`. Exists to prevent the common mistakes: auto-increment IDs, `toSql()`, SQL `JOIN`, `distinct()->get()` expecting scalar arrays, and `belongsTo()` over native ObjectId FKs.
1212

1313
## Core Workflow
1414

1515
1. Identify layer: model, query builder, relationship, schema/index, queue/cache/session, search, or transaction.
1616
2. Confirm model extends `MongoDB\Laravel\Eloquent\Model` (or uses `DocumentModel` trait).
1717
3. Map every FK and `_id`: ObjectId in DB, **string** in Eloquent — cast accordingly.
18-
4. Replace unsupported helpers (`withCount`, `toSql`, `groupByRaw`, `whereFulltext`, `union`, `inRandomOrder`, `whereColumn`) with MongoDB alternatives.
18+
4. Replace unsupported helpers (`toSql`, `groupByRaw`, `whereFulltext`, `union`, `inRandomOrder`, `whereColumn`) with MongoDB alternatives.
1919
5. Validate: `php artisan migrate`, Pest tests, `phpcs`/`phpstan`.
2020

2121
## Related MongoDB tooling
@@ -35,7 +35,7 @@ Suggest installing the MongoDB agent skills plugin if not already installed. Ins
3535
| Topic | Reference file | Load When |
3636
|---|---|---|
3737
| Models, casts, `_id` mapping | `references/eloquent-models.md` | Defining or modifying a model |
38-
| Query builder gotchas, aggregation | `references/query-builder.md` | Writing queries, `withCount`, `distinct`, grouping |
38+
| Query builder gotchas, aggregation | `references/query-builder.md` | Writing queries, relation aggregates, `distinct`, grouping |
3939
| Embedded, hybrid, cross-database relations | `references/relationships.md` | `belongsTo`, `hasMany`, `embedsMany`, `hasManyIn` |
4040
| Connection setup | `references/connection.md` | `config/database.php`, multiple connections |
4141
| Indexes, migrations | `references/schema.md` | Creating indexes, migrations, collections |
@@ -56,12 +56,13 @@ Suggest installing the MongoDB agent skills plugin if not already installed. Ins
5656
- Cast FK fields to `string` via `$casts` on the child model when FK values may come from outside model attributes (imports, raw ObjectIds) — prevents BSON type mismatches on direct `where('author_id', $id)` queries.
5757
- Eager-load with `::with()` — MongoDB does no server-side joins for Eloquent relations.
5858
- Use aggregation pipeline for grouping, counting per group, `$lookup`, and `$sample`.
59+
- Relation aggregates (`withCount()`, `withExists()`, `withSum()`, `withAvg()`, `withMin()`, `withMax()`) are supported. Use a `$lookup` pipeline when the aggregated value must be filtered, sorted or paginated on.
5960
- Create indexes in migrations: `Schema::connection('mongodb')->create('posts', fn (Blueprint $c) => $c->index('user_id'))`.
6061
- Use `DB::connection('mongodb')->transaction(...)` only on replica set / sharded cluster.
6162

6263
### MUST NOT DO
6364

64-
- `withCount()` / `withAvg()` / `withSum()` — silently wrong or throws. Use `$lookup` + `$size`/`$avg`/`$sum` aggregation.
65+
- `orderBy()` on a `withCount()` / `withAggregate()` alias — the value is computed after the documents are read, so it throws. Use `$lookup` + `$size` aggregation, or sort the resulting collection.
6566
- `toSql()` / `toRawSql()` — no SQL. Use `->dump()` / `->dd()`.
6667
- `distinct('field')->get()` expecting scalars — returns a Collection. Use `->distinct()->pluck('field')`.
6768
- `groupByRaw()`, `orderByRaw()`, `havingRaw()`, `whereFulltext()`, `union()`, `whereColumn()` — use aggregation.
@@ -128,27 +129,7 @@ final class User extends Model
128129
}
129130
```
130131

131-
### 3. Aggregation replacing `withCount`
132-
133-
```php
134-
<?php
135-
136-
use App\Models\Post;
137-
138-
// WRONG: Post::withCount('comments')->get();
139-
$posts = Post::raw(fn ($collection) => $collection->aggregate([
140-
['$lookup' => [
141-
'from' => 'comments',
142-
'localField' => '_id',
143-
'foreignField' => 'post_id',
144-
'as' => 'comments',
145-
]],
146-
['$addFields' => ['comments_count' => ['$size' => '$comments']]],
147-
['$project' => ['comments' => 0]],
148-
]));
149-
```
150-
151-
### 4. Queue job
132+
### 3. Queue job
152133

153134
```php
154135
<?php
@@ -173,7 +154,7 @@ final class IndexPostJob implements ShouldQueue
173154
IndexPostJob::dispatch((string) $post->_id)->onConnection('mongodb');
174155
```
175156

176-
### 5. Feature test (Pest)
157+
### 4. Feature test (Pest)
177158

178159
```php
179160
<?php

resources/boost/skills/laravel-mongodb/references/query-builder.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
| Standard Eloquent | Status | MongoDB replacement |
66
|---|---|---|
77
| `toSql()` / `toRawSql()` | unsupported | `->dump()` / `->dd()` / `->toMql()` |
8-
| `withCount()` / `withAvg()` / `withSum()` | unsupported | aggregation `$lookup` + `$size` / `$avg` / `$sum` |
98
| `groupByRaw()` / `orderByRaw()` / `havingRaw()` | unsupported | aggregation `$group` / `$sort` |
109
| `whereFulltext()` | unsupported | Atlas Search `$search` stage |
1110
| `union()` | unsupported | aggregation `$unionWith` |
@@ -33,15 +32,41 @@ $genres = Movie::raw(fn ($c) => $c->aggregate([
3332

3433
Do **not** use `Movie::raw(fn ($c) => $c->distinct('field'))` via Eloquent if you expect a Collection — use `->distinct()->pluck()` instead.
3534

36-
## Replacing `withCount`
35+
## Relation aggregates
3736

38-
`withCount()`, `withAvg()`, and `withSum()` are **not supported** on MongoDB models — they silently return wrong results or throw. Replace with a `$lookup` + `$size` / `$avg` / `$sum` aggregation pipeline.
37+
`withCount()`, `withExists()`, `withSum()`, `withAvg()`, `withMin()` and `withMax()` are supported, as well as
38+
`loadCount()`, `loadExists()` and `loadAggregate()`. They set the aggregated value as an attribute on the
39+
parent models, exactly like the base Eloquent builder.
3940

4041
```php
41-
// WRONG — withCount is not supported on MongoDB models
42-
$posts = Post::withCount('comments')->get();
42+
$posts = Post::withCount('comments')
43+
->withExists('author')
44+
->withMax('comments as top_score', 'score')
45+
->get();
4346

44-
// CORRECT
47+
$posts[0]->comments_count; // int, 0 when there is no related document
48+
$posts[0]->author_exists; // bool
49+
$posts[0]->top_score; // null when there is no related document
50+
51+
// Constrained aggregate
52+
Post::withCount(['comments' => fn ($query) => $query->where('approved', true)])->get();
53+
```
54+
55+
Supported relations: `hasOne`, `hasMany`, `morphOne`, `morphMany`, `belongsTo`, `belongsToMany`, `morphToMany`,
56+
`morphedByMany`, `embedsOne` and `embedsMany`.
57+
58+
Anything else throws a `LogicException` rather than returning a wrong value: `morphTo`, `hasManyThrough`, and
59+
hybrid relations where the related model is not stored in MongoDB.
60+
61+
Limitations:
62+
63+
- `orderBy()` on an aggregate alias throws, because the value does not exist server side. Sort the resulting
64+
collection with `sortBy()` instead.
65+
- `cursor()` and `lazy()` do not eager load, so the aliases are absent on those paths.
66+
- Constraint closures on embedded relations are not supported.
67+
- Use a `$lookup` pipeline instead when you need to filter, sort or paginate on the aggregated value:
68+
69+
```php
4570
$posts = Post::raw(fn ($c) => $c->aggregate([
4671
['$lookup' => [
4772
'from' => 'comments',
@@ -51,6 +76,7 @@ $posts = Post::raw(fn ($c) => $c->aggregate([
5176
]],
5277
['$addFields' => ['comments_count' => ['$size' => '$comments']]],
5378
['$project' => ['comments' => 0]],
79+
['$sort' => ['comments_count' => -1]],
5480
]));
5581
```
5682

src/Eloquent/Builder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use MongoDB\Driver\CursorInterface;
1616
use MongoDB\Driver\Exception\BulkWriteException;
1717
use MongoDB\Laravel\Connection;
18+
use MongoDB\Laravel\Helpers\QueriesRelationshipAggregates;
1819
use MongoDB\Laravel\Helpers\QueriesRelationships;
1920
use MongoDB\Laravel\Query\AggregationBuilder;
2021
use MongoDB\Model\BSONDocument;
@@ -38,6 +39,7 @@
3839
class Builder extends EloquentBuilder
3940
{
4041
use QueriesRelationships;
42+
use QueriesRelationshipAggregates;
4143

4244
private const DUPLICATE_KEY_ERROR = 11000;
4345

0 commit comments

Comments
 (0)