facile-it/mongodb-bundle
Symfony bundle that integrates Doctrine MongoDB ODM into your app, wiring document managers, repositories, and mappings through simple configuration so you can persist and query MongoDB documents using Symfony's service container.
facile-it/mongodb-bundle is a thin Symfony DI wrapper around the official mongodb/mongodb library. It is not Doctrine ODM: you get a configured MongoDB\Client, MongoDB\Database and (optionally) named MongoDB\Collection services, and you write queries by hand. That is the whole value proposition — no UnitOfWork, no hydrator, no schema.
Install
pecl install mongodb # or your distro package; ext-mongodb is mandatory
composer require facile-it/mongodb-bundle mongodb/mongodb
Check config/bundles.php — if Flex didn't auto-register it, add Facile\MongoDbBundle\FacileMongoDbBundle::class => ['all' => true].
Watch the version alignment matrix before you pin anything:
mongodb/mongodb 2.x requires ext-mongodb 2.x and PHP 8.1+; 1.x is the older pairing.Configure
# config/packages/mongodb.yaml
mongodb:
connections:
default:
uri: '%env(resolve:MONGODB_URI)%' # e.g. mongodb://mongo:27017/?replicaSet=rs0
database: '%env(resolve:MONGODB_DATABASE)%'
options: {} # connectTimeoutMS, serverSelectionTimeoutMS, ...
MONGODB_URI=mongodb://mongo:27017
MONGODB_DATABASE=app
The exact key names have shifted between releases — run bin/console config:dump-reference mongodb against your pinned version before copying any snippet from a blog post.
Where to look first
bin/console debug:container mongodb and bin/console debug:autowiring mongodb — confirms whether MongoDB\Client / MongoDB\Database are actually aliased in your version and how the connections are named.mongodb/mongodb library docs — 90% of your day-to-day API knowledge lives there, not in the bundle.First use case — inject the database, write a document:
final class AuditLogger
{
public function __construct(private MongoDB\Database $db) {}
public function log(string $event, array $context): void
{
$this->db->selectCollection('audit')->insertOne([
'event' => $event,
'context' => $context,
'occurredAt' => new MongoDB\BSON\UTCDateTime(),
]);
}
}
If nothing is autowirable, fall back to explicit arguments in services.yaml:
services:
MongoDB\Database:
alias: mongodb.connection.default.database # verify the id with debug:container
Always put a repository/DAO layer between Symfony and the driver. This is the single highest-leverage decision. It keeps MongoDB\BSON\* types and ArrayObject-ish documents out of controllers, and it makes the code testable, since MongoDB\Collection is awkward to fake at the call site.
final class UserRepository
{
public function __construct(private MongoDB\Collection $users) {}
public function findById(string $id): ?User
{
$doc = $this->users->findOne(
['_id' => new MongoDB\BSON\ObjectId($id)],
['typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array']],
);
return $doc ? User::fromDocument($doc) : null;
}
public function rename(string $id, string $name): void
{
$this->users->updateOne(
['_id' => new MongoDB\BSON\ObjectId($id)],
['$set' => ['name' => $name, 'updatedAt' => new MongoDB\BSON\UTCDateTime()]],
);
}
}
Wire named collections via the bundle's collection config, then alias per type-hint. If the bundle's collection factory isn't available in your version, register your own:
services:
app.mongo.users:
factory: ['@MongoDB\Database', 'selectCollection']
arguments: ['users']
MongoDB\Collection $usersCollection: '@app.mongo.users'
App\Repository\UserRepository:
arguments: ['$usersCollection']
Force root: array in a shared type map. The default root is MongoDB\Model\BSONDocument (an ArrayObject), which behaves differently from a plain array in isset, array_map, and serializer code. Passing typeMap explicitly per call, or setting it globally on the client options, removes an entire class of "why is this {} in my JSON" bugs. Map to domain DTOs yourself in fromDocument() — this is your "hydrator."
Use findOneAndUpdate with ReturnDocument::AFTER for read-modify-write rather than a findOne + updateOne pair:
$updated = $collection->findOneAndUpdate(
['_id' => $id, 'status' => 'pending'],
['$set' => ['status' => 'done'], '$inc' => ['attempts' => 1]],
['returnDocument' => MongoDB\Operation\FindOneAndUpdate::RETURN_DOCUMENT_AFTER],
);
if ($updated === null) { /* no match — not an error, but handle it */ }
Aggregation lives in the repository, not in Twig/controllers. aggregate() returns a lazy MongoDB\Driver\Cursor; call ->toArray() or foreach and map to DTOs. Keep $lookup rare — if you need three of them, the data model is probably wrong for MongoDB.
Transactions require a replica set. $client->startSession() + $session->withTransaction(...) is the pattern, but against a standalone mongo Docker container it throws. If you need them, put replicaSet in the URI and initialize with rs.initiate() in your dev/test fixture.
Indexes are your "migrations." There is no schema and no migration tool. Add a console command (or a post-deploy script) that runs idempotent createIndex() / createIndexes() calls for every collection. Do this early — a missing index is the most common production incident with this stack.
Log slow queries with a command subscriber, not by wrapping the client:
final class SlowQueryLogger implements MongoDB\Driver\Monitoring\CommandSubscriber
{
public function __construct(private LoggerInterface $logger) {}
public function commandStarted(MongoDB\Driver\Monitoring\CommandStartedEvent $e): void { /* start timer */ }
public function commandSucceeded(MongoDB\Driver\Monitoring\CommandSucceededEvent $e): void { /* log if > threshold */ }
public function commandFailed(MongoDB\Driver\Monitoring\CommandFailedEvent $e): void {}
}
Register it via a compiler pass or a kernel.event_subscriber-style boot hook calling $client->getManager()->addSubscriber($logger) in dev/test only.
Testing strategy: unit-test repositories against a mocked MongoDB\Collection (PHPUnit can bypass the Manager-requiring constructor), and keep a small integration suite against a real MongoDB via Docker. If mocking gets painful, define your own UserStore interface, implement it with the collection, and fake the interface in tests — the thin bundle makes this cheap. Pin your CI Mongo image to 5.0+ to match what the bundle itself tests.
findOne calls return two distinct PHP values even for the same _id. Don't port Doctrine habits (flush/persist) — every write is an explicit operation.composer update float you across the major. The DI surface and config keys are unchanged in 2.0 as far as the release notes go — the break is the platform floor, not the API.ObjectId, UTCDateTime, Decimal128, and binary are not what symfony/serializer or json_encode expects. Convert at the DTO boundary: (string) $objectId, $utcDateTime->toDateTime()->format(DATE_ATOM). Do this in your fromDocument()/toDocument() mappers, not in the view layer.BSONDocument is not an array. Default hydration gives you an ArrayObject; $doc['field'] works, $doc->field does not, and array_key_exists needs getArrayCopy(). Setting typeMap.root = array sidesteps all of it.typeMap root hydration is limited. You can map to a class, but it's reflection-based, doesn't call constructors, ignores private/typed-uninitialized properties, and doesn't recurse into nested documents unless you configure fieldPaths. For anything non-trivial, hydrate manually._id is immutable. An $set on _id throws MongoDB\Driver\Exception\BulkWriteException. Same for updating the shard key. Model this in your update methods.findOneAndUpdate returns null on no match (with BEFORE and no upsert). null is a legitimate "nothing matched", not an exception — decide explicitly whether to throw a 404/409.MongoDB\Driver\Exception\DuplicateKeyException (code 11000) or inspect BulkWriteException::getWriteResult()->getWriteErrors() and map to HTTP 409. Without a unique index you'll never see this, so create the indexes.aggregate() is lazy. A Cursor that's never iterated never hits the server. Forgetting toArray() is a classic "my aggregation returns nothing" bug.% in credentials breaks YAML/env resolution. Use %env(resolve:...)% and URL-encode the password, or the DSN gets mangled.mongodb+srv need infrastructure: replica set, correct authSource, working DNS SRV records. Test the exact connection string in CI, not just locally.serverSelectionTimeoutMS, connectTimeoutMS, and socketTimeoutMS deliberately, and be prepared to catch ConnectionTimeoutException on the first command after idle. Don't hold a MongoDB\Collection inside a serializable message payload.new MongoDB\Client() in a service. Otherwise your URI options, command subscribers, and profiler instrumentation silently don't apply.bin/console debug:container mongodb, debug:autowiring MongoDB, and a ping in a health controller ($client->selectDatabase('admin')->command(['ping' => 1])) covers 95% of "the connection is broken" reports.^2.0 on Symfony 5.4–8.x, ^1.0 if you're stuck on Symfony 4), read the changelog before minor bumps (the config keys and service ids have moved historically), and keep your whole integration surface inside one repository package so swapping to hand-rolled client registration is an afternoon's work if you ever need to.How can I help you explore Laravel packages today?