solution/mongo-odm-aggregation-bundle
Symfony bundle that adds a fluent aggregation builder for Doctrine MongoDB ODM, making it easier to construct, run, and reuse complex MongoDB aggregation pipelines in PHP applications.
Install (legacy Symfony 2.x only)
composer require solution/mongo-odm-aggregation-bundle
Register the bundle in app/AppKernel.php:
$bundles = [
// ...
new Solution\MongoAggregationBundle\SolutionMongoAggregationBundle(),
];
You must already have doctrine/mongodb-odm-bundle wired up and configured — this bundle does not manage connections, it only adds aggregation on top of an existing ODM setup.
Where to look first
Resources/config/services.xml (or .yml) — this tells you the public service id and its constructor arguments. That is the single most important file; everything below assumes you find the service name there.DependencyInjection/Configuration.php — any bundle-level config keys (default options like allowDiskUse, cursor settings).DocumentManager and how it exposes aggregate().First use case
The canonical first exercise: aggregate a collection you already model with ODM. For example, group orders per customer and sum totals:
$dm = $this->get('doctrine_mongodb')->getManager();
$collection = $dm->getDocumentCollection('AppBundle:Order');
$pipeline = [
['$group' => [
'_id' => '$customerId',
'total' => ['$sum' => '$amount'],
'count' => ['$sum' => 1],
]],
['$sort' => ['total' => -1]],
['$limit' => 10],
];
$results = $service->aggregate($collection, $pipeline)->toArray();
Start with a pipeline that works in the mongo/mongosh shell first, then port it into PHP as a plain array. The bundle is thin over the driver, so the shell is your reference implementation.
Wrap the service, don't call it from controllers
The highest-leverage pattern is to build aggregation into your repositories or a small "report" layer, so controllers stay thin:
class OrderRepository extends DocumentRepository
{
public function topCustomers(int $limit = 10): array
{
return $this->createAggregationBuilder()
->group()
->field('_id')->expression('$customerId')
->field('total')->sum('$amount')
->sort('total', 'desc')
->limit($limit)
->getQuery()
->execute()
->toArray();
}
}
Whether the bundle gives you a fluent builder or only a raw pipeline array, keeping the pipeline construction in one repository method per report is what stops this from becoming unmaintainable.
Keep pipelines as named, reusable definitions
Store pipelines in dedicated methods or a PipelineFactory class rather than inlining arrays. Aggregation pipelines are the most brittle part of a Mongo codebase — when a $group key or $lookup shape changes, you want a single place to edit and a clear name to grep for.
Filter early with $match, then group
A common day-to-day pattern: build a $match stage from your existing ODM QueryBuilder criteria, prepend it to the pipeline, and let the database do the filtering before any $group/$lookup. Avoid pulling documents into PHP and grouping there.
Handle $lookup results explicitly
When you join another collection, the joined field is always an array even for a single match. Flatten deliberately:
['$unwind' => ['path' => '$customer', 'preserveNullAndEmptyArrays' => true]],
preserveNullAndEmptyArrays is what keeps orphaned documents in the output — an easy thing to forget when "some rows disappear" in production.
Map to DTOs at the boundary
Aggregation results are plain PHP arrays, not hydrated ODM documents. Convert them to typed value objects (or plain arrays with a documented shape) before returning to the rest of the app, so the rest of your code isn't coupled to raw Mongo result shape.
Test against a real mongod
Unit-mocking the ODM cursor is not worth it — aggregation semantics live in the server. Spin up a throwaway mongod (or use a fixture database) and assert on actual pipeline output. Keep a few "golden pipeline" tests that fail loudly when Mongo changes behaviour across versions.
Integration tips
$this->get() inside deep code.ReportService that takes the collection name and pipeline is a clean seam.$skip/$limit as the final stages — not a LIMIT on a cursor.It is Symfony 2-only and effectively unmaintained. Last release 2016, 12 stars, zero dependents. It will not install cleanly on Symfony 3.4+/4+/5+/6+, and its Doctrine ODM dependency constraints pin you to old doctrine/mongodb-odm versions. If you are on anything modern, budget a day to replace it with a thin service that calls Collection::aggregate() directly — the bundle's value is small enough that the replacement is usually cheaper than fighting compatibility. Treat this as a "reference implementation to copy", not a dependency.
Doctrine ODM API drift is the real trap. The AggregationBuilder API (->group()->field()...) and ->aggregate() return types changed between ODM 1.x and later versions. Code samples from this bundle's era (->aggregate() returning a ResultCollection, ->toArray()) may not match your installed version. Pin the ODM version in composer.json and check the installed class names before copying snippets.
Results are arrays, not documents. Don't expect $dm->getRepository(...)->find()-like hydration. If a downstream consumer expects entities, map explicitly. Same for _id fields in $group output — they are the group key, not necessarily an ObjectId.
BSON types bite in $match. Filtering on a customerId stored as ObjectId requires new \MongoId(...) (old driver) / new \MongoDB\BSON\ObjectId(...) (new driver), not the raw string. A string comparison silently returns zero rows. Same with dates: pass \DateTime, not a string.
$group with _id: null is the global aggregate. That's the pattern for a grand total — but be aware it forces a single document and can blow the 16 MB limit; add allowDiskUse and consider a $out/$merge collection for large jobs.
Pass allowDiskUse for anything $sort-heavy or with big $group stages. In-memory sorts cap at 100 MB. On old servers, this is a hard failure, not a slowdown. If the bundle exposes default options, set allowDiskUse: true there so every pipeline benefits.
Cursor batching / memory. ->toArray() on a large result set materialises everything in PHP. For reports, iterate the cursor or add a $limit. Watch BatchSize if the bundle lets you set it.
Debugging a pipeline. The fastest loop is: log the final pipeline array (before sending it), then paste it into mongosh and run db.collection.explain('executionStats').aggregate([...]). If the bundle wraps the ODM cursor, the Doctrine profiler in Symfony's web bar will show the command — enable it in config_dev.yml and confirm the pipeline that was actually sent matches what you intended.
Extension points. If you need shared default stages, override the bundle's main service in your services.yml with a subclass, or add a decorator. There is no plugin architecture to speak of — a decorator around the aggregation service is the cleanest seam.
Version pinning checklist before you commit to this bundle: required Symfony version, required doctrine/mongodb-odm version, PHP version (2016-era code likely has no strict_types, may not run on PHP 8), and whether the driver is ext-mongo (dead) or ext-mongodb. If any of those say "old", plan the extraction now rather than after it becomes load-bearing.
How can I help you explore Laravel packages today?