MongoDB #
MongoDB is a NoSQL database that stores data in BSON (Binary JSON) documents — unlike relational databases that store data in rigid tables with rows and columns, MongoDB stores documents whose structures can differ within a single collection. This is very useful for data with frequently changing schemas, naturally hierarchical data (products with diverse attributes, event logs, CMS content), or when you need easy horizontal scaling. PHP accesses MongoDB through the official mongodb/mongodb library built on top of the PECL mongodb extension. This article covers all the important aspects: CRUD, the powerful aggregation pipeline, indexing, transactions, and when MongoDB is the right choice.
When MongoDB Is the Right Choice #
flowchart TD
A{Data has a\nfixed schema?} -- Yes --> B{Need complex\nJOINs?}
B -- Yes --> C[Relational Database\nMySQL / PostgreSQL]
B -- No --> D{Very high\nwrite volume?}
D -- Yes --> E[MongoDB\nOr a time-series DB]
D -- No --> C
A -- No --> F{Data structure\nchanges often?}
F -- Yes --> G[MongoDB\nFlexible schema]
F -- No --> H{Hierarchical\nor document data?}
H -- Yes --> G
H -- No --> C
style C fill:#dbeafe
style G fill:#dcfce7Choose MongoDB if:
✓ The schema changes often — diverse product attributes, CMS content
✓ Data is naturally hierarchical — orders with embedded items
✓ High read/write volume with horizontal scalability
✓ Logging, analytics, time-series, event stores
✓ Fast prototyping without schema migrations
Choose a relational database if:
✗ Data is highly structured with complex relations
✗ You need strong ACID transactions across many collections
✗ Complex reports and analytical queries
✗ The team is more familiar with SQL
Installation #
# 1. Install the PHP mongodb extension via PECL
sudo apt install php8.3-dev php-pear
sudo pecl install mongodb
# Enable the extension
echo "extension=mongodb.so" | sudo tee /etc/php/8.3/mods-available/mongodb.ini
sudo phpenmod mongodb
# 2. Install the PHP library (abstraction on top of the extension)
composer require mongodb/mongodb
# Verify
php -r "echo extension_loaded('mongodb') ? 'OK' : 'Not available'; echo PHP_EOL;"
Connecting #
<?php
require 'vendor/autoload.php';
use MongoDB\Client;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\UTCDateTime;
// Basic connection
$client = new Client('mongodb://localhost:27017');
// With authentication
$client = new Client('mongodb://username:***@localhost:27017/authdb');
// With connection options
$client = new Client('mongodb://localhost:27017', [
'serverSelectionTimeoutMS' => 3000,
'connectTimeoutMS' => 5000,
'socketTimeoutMS' => 30000,
'maxPoolSize' => 10,
]);
// MongoDB Atlas (cloud)
$client = new Client(
'mongodb+srv://username:***@cluster.mongodb.net/?retryWrites=true&w=majority'
);
// Select a database and collection
$db = $client->myapp; // database
$users = $db->users; // users collection
$products = $client->myapp->products; // shorthand
$orders = $client->selectCollection('myapp', 'orders'); // explicit way
CRUD Operations #
Create — Storing Documents #
<?php
// insertOne — one document
$result = $users->insertOne([
'name' => 'Budi Santoso',
'email' => '[email protected]',
'age' => 28,
'active' => true,
'tags' => ['php', 'mongodb', 'backend'],
'address' => [
'street' => 'Jl. Sudirman No. 1',
'city' => 'Jakarta',
'postalCode'=> '10220',
],
'created_at' => new UTCDateTime(), // current time
]);
echo "New ID: " . $result->getInsertedId() . "\n"; // ObjectId
// insertMany — many documents at once
$manyResult = $products->insertMany([
['name' => 'Pro Laptop', 'price' => 15000000, 'stock' => 5, 'category' => 'electronics'],
['name' => '4K Monitor', 'price' => 5000000, 'stock' => 12, 'category' => 'electronics'],
['name' => 'Ergo Mouse', 'price' => 250000, 'stock' => 0, 'category' => 'accessories'],
['name' => 'Mech Keyboard','price' => 800000, 'stock' => 8, 'category' => 'accessories'],
]);
echo "Inserted: " . $manyResult->getInsertedCount() . " documents\n";
echo "IDs: " . implode(', ', array_map('strval', $manyResult->getInsertedIds())) . "\n";
Read — Finding Documents #
<?php
// findOne — one document
$user = $users->findOne(['email' => '[email protected]']);
if ($user !== null) {
echo $user['name'] . "\n"; // Budi Santoso
echo $user['_id'] . "\n"; // ObjectId as a string
}
// Find by ObjectId
$id = new ObjectId('64f5a1b2c3d4e5f6a7b8c9d0');
$user = $users->findOne(['_id' => $id]);
// find — many documents with a filter
$cursor = $products->find(
// Filter
['category' => 'electronics', 'stock' => ['$gt' => 0]],
// Options
[
'projection' => ['name' => 1, 'price' => 1, '_id' => 0], // select fields
'sort' => ['price' => 1], // ascending
'limit' => 10,
'skip' => 0,
]
);
foreach ($cursor as $doc) {
echo "{$doc['name']}: Rp " . number_format($doc['price']) . "\n";
}
// MongoDB query operators
$results = $products->find([
'price' => ['$gte' => 100000, '$lte' => 5000000], // price range
'stock' => ['$gt' => 0], // stock > 0
'category' => ['$in' => ['electronics', 'accessories']], // one of
'name' => ['$regex' => 'Laptop', '$options' => 'i'], // case-insensitive regex
]);
// $or — at least one condition matches
$results = $users->find([
'$or' => [
['role' => 'admin'],
['active' => true, 'age' => ['$gte' => 18]],
]
]);
// countDocuments — count matching documents
$total = $products->countDocuments(['category' => 'electronics', 'stock' => ['$gt' => 0]]);
// distinct — unique values of one field
$categories = $products->distinct('category');
// ['electronics', 'accessories']
Update — Modifying Documents #
<?php
// updateOne — update one document
$result = $products->updateOne(
['_id' => new ObjectId('...')], // filter
[
'$set' => ['price' => 14000000, 'updated_at' => new UTCDateTime()],
'$inc' => ['stock' => -1], // decrease stock by 1
'$push' => ['tags' => 'sale'], // add to an array
]
);
echo "Matched: " . $result->getMatchedCount() . "\n";
echo "Modified: " . $result->getModifiedCount() . "\n";
// updateMany — update all matches
$products->updateMany(
['category' => 'electronics', 'stock' => ['$gt' => 0]],
['$mul' => ['price' => 1.1]] // increase prices by 10%
);
// findOneAndUpdate — find, update, and return the document
$oldDoc = $products->findOneAndUpdate(
['_id' => new ObjectId('...')],
['$set' => ['status' => 'sold_out']],
['returnDocument' => \MongoDB\Operation\FindOneAndUpdate::RETURN_DOCUMENT_AFTER]
// RETURN_DOCUMENT_AFTER: return the document AFTER the update
// RETURN_DOCUMENT_BEFORE: return the document BEFORE the update (default)
);
// upsert — update if it exists, insert if it doesn't
$result = $products->updateOne(
['sku' => 'LAP-001'],
[
'$set' => ['name' => 'Laptop Pro X', 'price' => 16000000],
'$setOnInsert' => ['created_at' => new UTCDateTime()], // only on insert
],
['upsert' => true]
);
echo $result->isUpsert() ? "New document created\n" : "Document updated\n";
// Important update operators:
// $set — set a field's value
// $unset — remove a field
// $inc — increment/decrement numbers
// $mul — multiply a value by a factor
// $push — add an element to an array
// $pull — remove an element from an array
// $addToSet — add to an array only if not already present
// $rename — rename a field
// $currentDate — set to the current date
Delete — Removing Documents #
<?php
// deleteOne
$result = $users->deleteOne(['email' => '[email protected]']);
echo "Deleted: " . $result->getDeletedCount() . "\n";
// deleteMany
$result = $products->deleteMany([
'stock' => 0,
'updated_at' => ['$lt' => new UTCDateTime(strtotime('-30 days') * 1000)],
]);
// findOneAndDelete — find, delete, and return the deleted document
$deleted = $orders->findOneAndDelete(['status' => 'cancelled', 'total' => ['$lt' => 10000]]);
if ($deleted !== null) {
echo "Deleted order: " . $deleted['_id'] . "\n";
}
Aggregation Pipelines #
The aggregation pipeline is the most powerful way to analyze data in MongoDB — similar to SQL’s GROUP BY, JOIN, and HAVING combined:
<?php
// Sales report per category
$pipeline = [
// Stage 1: Filter documents
['$match' => ['status' => 'completed', 'created_at' => ['$gte' => new UTCDateTime(strtotime('-30 days') * 1000)]]],
// Stage 2: Lookup (JOIN) into the products collection
['$lookup' => [
'from' => 'products', // source collection
'localField' => 'product_id', // field in orders
'foreignField' => '_id', // field in products
'as' => 'product_info',
]],
// Stage 3: Unwind the lookup result array
['$unwind' => '$product_info'],
// Stage 4: Group and aggregate
['$group' => [
'_id' => '$product_info.category',
'total_sales' => ['$sum' => '$total'],
'order_count' => ['$sum' => 1],
'average' => ['$avg' => '$total'],
'largest' => ['$max' => '$total'],
]],
// Stage 5: Add new fields
['$addFields' => [
'category' => '$_id',
]],
// Stage 6: Drop unnecessary fields
['$project' => [
'_id' => 0,
'category' => 1,
'total_sales' => 1,
'order_count' => 1,
'average' => ['$round' => ['$average', 0]],
'largest' => 1,
]],
// Stage 7: Sort
['$sort' => ['total_sales' => -1]],
// Stage 8: Limit results
['$limit' => 10],
];
$results = $db->orders->aggregate($pipeline);
foreach ($results as $row) {
printf(
"%s: %d orders, total Rp %s\n",
$row['category'],
$row['order_count'],
number_format($row['total_sales'])
);
}
Indexing #
Indexes in MongoDB are critical for query performance. Without an index, MongoDB must scan the entire collection:
<?php
// Single index
$users->createIndex(['email' => 1], ['unique' => true, 'name' => 'idx_email_unique']);
// Compound index
$products->createIndex(
['category' => 1, 'price' => 1], // ascending
['name' => 'idx_category_price']
);
// Descending index
$orders->createIndex(['created_at' => -1], ['name' => 'idx_created_desc']);
// Text index for full-text search
$products->createIndex(
['name' => 'text', 'description' => 'text'],
['name' => 'idx_text_search', 'weights' => ['name' => 10, 'description' => 1]]
);
// Use the text index
$results = $products->find(['$text' => ['$search' => 'laptop gaming']]);
// TTL index — documents automatically deleted after a certain time
$sessions->createIndex(
['expired_at' => 1],
['expireAfterSeconds' => 0, 'name' => 'idx_ttl_session']
);
// Documents with expired_at in the past are automatically removed
// Partial index — only index documents matching a condition
$products->createIndex(
['price' => 1],
['partialFilterExpression' => ['stock' => ['$gt' => 0]], 'name' => 'idx_price_available']
);
// List all indexes
$indexes = iterator_to_array($products->listIndexes());
foreach ($indexes as $index) {
echo $index->getName() . ": " . json_encode($index->getKey()) . "\n";
}
// Drop an index
$products->dropIndex('idx_category_price');
Multi-Document Transactions #
MongoDB 4.0+ supports ACID transactions for operations involving multiple documents or collections:
<?php
// Transactions require a Replica Set or Sharded Cluster
// Not possible on a standalone mongod
$session = $client->startSession();
try {
$session->startTransaction([
'readConcern' => new \MongoDB\Driver\ReadConcern('snapshot'),
'writeConcern' => new \MongoDB\Driver\WriteConcern(\MongoDB\Driver\WriteConcern::MAJORITY),
]);
// All operations in the transaction must include the session
$orders->insertOne(
[
'user_id' => new ObjectId('...'),
'items' => [['product_id' => new ObjectId('...'), 'qty' => 2]],
'total' => 30000000,
'status' => 'pending',
],
['session' => $session]
);
// Decrease the stock
$products->updateOne(
['_id' => new ObjectId('...')],
['$inc' => ['stock' => -2]],
['session' => $session]
);
$session->commitTransaction();
echo "Transaction successful\n";
} catch (\MongoDB\Driver\Exception\CommandException $e) {
$session->abortTransaction();
throw $e;
} finally {
$session->endSession();
}
GridFS — Storing Large Files #
GridFS is the MongoDB specification for storing files larger than the document size limit (16MB):
<?php
use MongoDB\GridFS\Bucket;
$bucket = $db->selectGridFSBucket(['bucketName' => 'uploads']);
// Upload a file
$stream = fopen('/path/to/image.jpg', 'rb');
$fileId = $bucket->uploadFromStream('image.jpg', $stream, [
'metadata' => ['uploader' => 'Budi', 'category' => 'product'],
]);
fclose($stream);
echo "File ID: $fileId\n";
// Download a file
$outputStream = fopen('/tmp/download.jpg', 'wb');
$bucket->downloadToStream($fileId, $outputStream);
fclose($outputStream);
// Stream to the browser
header('Content-Type: image/jpeg');
$downloadStream = $bucket->openDownloadStream($fileId);
fpassthru($downloadStream);
// Find files
$cursor = $bucket->find(['metadata.category' => 'product']);
foreach ($cursor as $fileDoc) {
echo $fileDoc->filename . " (" . $fileDoc->length . " bytes)\n";
}
// Delete a file
$bucket->delete($fileId);
Common MongoDB Anti-Patterns #
<?php
// ✗ Anti-pattern 1: queries without indexes on large collections
$products->find(['name' => 'Laptop']); // full collection scan if 'name' isn't indexed
// ✓ Create an index: $products->createIndex(['name' => 1]);
// ✗ Anti-pattern 2: storing large documents with unbounded growing arrays
$orders->updateOne(
['_id' => $orderId],
['$push' => ['log' => "Event $i"]] // the log array can grow unbounded!
);
// ✓ Store logs in a separate collection, not embedded in the document
// ✗ Anti-pattern 3: unnecessary string _ids
$products->insertOne(['_id' => 'laptop-001', 'name' => 'Laptop']); // allowed, but...
// If 'laptop-001' isn't unique across your system, use an ObjectId that's guaranteed unique
// ✗ Anti-pattern 4: joining too many collections in aggregations
// MongoDB isn't a relational database — if you need many $lookups, consider:
// - Embed data that's frequently accessed together
// - Use a relational database if complex relations are the core requirement
// ✗ Anti-pattern 5: no timeout on operations
$cursor = $products->find([], ['noCursorTimeout' => true]); // a cursor that never times out — dangerous!
// ✓ Keep the default timeout, or set maxTimeMS
$cursor = $products->find([], ['maxTimeMS' => 5000]); // 5-second timeout
Summary #
- The
mongodb/mongodblibrary is the official abstraction on top of the PECL extension — always use this library, not the raw extension.- BSON data types — use
MongoDB\BSON\ObjectIdfor IDs,MongoDB\BSON\UTCDateTimefor dates (not plain PHP DateTime), andMongoDB\BSON\Decimal128for high-precision decimal numbers.- Query operators:
$gt/$gte/$lt/$lte(comparisons),$in/$nin(in lists),$regex(patterns),$or/$and/$not(logic),$exists(field presence).- Update operators:
$set(set a value),$unset(remove a field),$inc(increment),$push/$pull(arrays),$addToSet(add unique to an array).- The aggregation pipeline is the most powerful way to do analytics — use
$matchfirst to filter before$group,$lookup, etc. so only the needed data is processed.- Indexes are mandatory for all frequently queried fields. Use
explain()to verify queries use an index. TTL indexes are great for data with an expiry time (sessions, caches, tokens).- Multi-document transactions are available in MongoDB 4.0+ but require a Replica Set. For standalone, atomicity is only guaranteed at the single-document level.
- GridFS for files > 16MB — don’t store large files directly in a regular document field.