Get expert guidance in a few days with a Laravel code review

Querying Data in MongoDB With Laravel: From Basics to Advanced Techniques

Last updated on by

Querying Data in MongoDB With Laravel: From Basics to Advanced Techniques image

MongoDB has grown into one of the most popular NoSQL databases thanks to its flexible, document-based structure. Unlike traditional SQL databases that store data in rows and tables, MongoDB stores data as JSON-like documents. This makes it especially well-suited for modern applications where schema flexibility, nested data, and scalability are key.

Laravel, on the other hand, is one of the most widely used PHP frameworks. It provides developers with a clean, expressive syntax and powerful features such as Eloquent ORM, middleware, and artisan commands. When combined with MongoDB, Laravel developers get the best of both worlds: a robust PHP framework and a highly scalable, flexible database.

In this article, we’ll walk through querying data in MongoDB with Laravel—starting with the basics, moving through advanced techniques, and ending with performance optimizations and testing strategies. By the end, you’ll have a solid understanding of how to query MongoDB effectively within a Laravel application.

Setting up MongoDB with Laravel

Before writing queries, you’ll need to configure your Laravel project to work with MongoDB. This setup step ensures that Laravel can talk to your MongoDB database just like it does with MySQL or PostgreSQL. The MongoDB Laravel package extends Laravel’s database layer to support collections, documents, and MongoDB-specific query operators.

Step 1: Install the package

In your Laravel project, install the MongoDB Laravel package via Composer. As of the writing of this article, the recommended version is ^5.5. This version ensures compatibility with Laravel 11 and PHP 8.3. Always check the official documentation for the latest guidance, since package versions evolve over time.

composer require mongodb/laravel-mongodb:^5.5

Step 2: Configure environment variables

Open your project’s .env file and add the MongoDB connection details. If you’re using MongoDB Atlas, you’ll find a connection string in the Atlas dashboard when you create a cluster. That string includes your cluster host, username, and password.

DB_CONNECTION=mongodb
MONGODB_URI="mongodb+srv://<username>:<password>@cluster0.mongodb.net"
MONGODB_DATABASE=laravel_mongo_demo
  • MONGODB_URI: This is the full connection string. Atlas uses the +srv format, while local MongoDB might look like mongodb://127.0.0.1:27017.
  • MONGODB_DATABASE: This is the default database to connect to, similar to choosing a schema in SQL.

If you’re new to MongoDB Atlas, you can follow the Atlas Getting Started guide to create your cluster, add a database user, and whitelist your IP.

Step 3: Update database configuration

Now, we need to insert the MongoDB database configuration inside the config/database.php file for Laravel to be able to find and use it in the .env file:

'mongodb' => [
'driver' => 'mongodb',
'dsn' => env('MONGODB_URI'),
'database' => env('MONGODB_DATABASE'),
],

This ensures that when you use the mongodb connection, Laravel routes your queries through the MongoDB driver instead of SQL. These steps are well documented in the official Laravel documentation. Feel free to consult that to keep up to date with the latest instructions.

Step 4: Create a MongoDB model

Models in the case of MongoDB serve the same purpose as in any SQL database, except that MongoDB models extend a special base class provided by the laravel-mongodb package. For example, to create a Post model:

php artisan make:model Post

Update the generated model class to extend from MongoDB\Laravel\Eloquent\Model base class:

use MongoDB\Laravel\Eloquent\Model;
 
class Post extends Model
{
protected $connection = 'mongodb';
protected $collection = 'posts';
protected $fillable = ['title','body','author','status','created_at'];
}

Here:

  • connection tells Laravel which connection to use.
  • collection specifies the MongoDB collection, equivalent to a table in SQL.
  • fillable defines which fields can be mass-assigned when creating documents.

To learn about the different configurable protected fields or the methods that can be overridden, take a look at the code of the base Model class. These base classes are very well documented PHP code that you'll be able to understand easily. Never be afraid to dig into the source codes of packages you're using.

Step 5: Run a quick test query

There are multiple ways of testing this setup, but two that come to mind immediately are Laravel Tinker and a simple closure-based controller.

Option A: Laravel tinker

Laravel Tinker is a REPL that allows you to execute any part of your Laravel project without hitting the complete project lifecycle.

php artisan tinker
Post::create([
'title' => 'First Post',
'body' => 'Testing MongoDB with Laravel.',
'author' => 'Admin',
'status' => 'published'
]);
 
Post::all();

For example, you can run the above piece of code to create a new database entry without running the application itself with Tinker.

Option B: closure route You can also take the traditional route and use a simple closure to execute the same bit of code.

// routes/web.php
Route::get('/test-mongo', function () {
Post::create([
'title' => 'First Post',
'body' => 'Testing MongoDB with Laravel.',
'author' => 'Admin',
'status' => 'published'
]);
return Post::all();
});

Visit /test-mongo in your browser to confirm that data is being written and read.

Now, use MongoDB Compass or inspect the posts collection directly on Atlas to make sure a new document has been written. Documents in MongoDB are equivalent to rows in SQL, while collections are equivalent to tables.

Basic querying fundamentals

With the connection in place, you can begin writing queries. Laravel gives you two main ways to interact with MongoDB: the query builder and Eloquent ORM. Understanding when to use each will help you balance simplicity and performance.

Query builder vs. Eloquent ORM

  • Eloquent ORM treats each document as a model instance. It’s great for readability, relationships, and model-specific logic like accessors and mutators. Use this when building features where business logic lives close to the data model (e.g., posts, users, comments).
  • Query builder is a lighter layer that talks directly to the collection. It returns arrays instead of model objects. Use this for performance-sensitive queries, analytics, or when you don’t need the overhead of full models.

However, this doesn't mean that using the query builder over the ORM is always a good idea. The ideal way is to start with the ORM and adapt the query builder for queries with significant performance cost.

Creating documents

The usage of Eloquent is identical to any SQL database. You just treat the model class as a facade and call the action you want—like the create action, in this example.

Post::create([
'title' => 'Second Post',
'body' => 'Inserted with Eloquent.',
'author' => 'Jane Doe',
'status' => 'draft',
]);

Here, each key corresponds to a field in your MongoDB document. Eloquent takes care of timestamps and casting, if you’ve configured them.

The query builder, on the other hand, requires the usage of the DB facade. Since the builder talks to the database directly, it's a bit verbose.

DB::connection('mongodb')->collection('posts')->insert([
'title' => 'Third Post',
'body' => 'Inserted with Query Builder.',
'author' => 'John Doe',
'status' => 'published',
]);

If you're not that familiar with facades, consult the official documentation. You can also go through the documentation for the query builder to learn more about the available methods.

Reading documents

The go-to technique for fetching all records from a table or collection in Laravel using Eloquent is to use the all() method.

$posts = Post::all();

This returns a collection of Post model instances, letting you access fields like $post->title. Collections are similar to arrays but have a lot more functionality. You can learn about them in the official documentation.

While using the query builder, the get() call without any condition gives a similar result.

$posts = DB::connection('mongodb')->collection('posts')->get();

This returns plain arrays, useful if you just need raw data. Also, the elements in the array are not model instances but objects, so calling relationship methods will not work the way it does in the case of Eloquent.

There are also methods for fetching single records from the database based on a given filter:

$post = Post::find($id); // fetch by document _id
$first = Post::where('status', 'published')->first(); // first matching document

Updating documents

Updating any record involves first pinning down the records you want to update using a where() clause and using the update() clause to perform the actual update operation.

Post::where('status', 'draft')->update(['status' => 'published']);

Deleting documents

Deleting is very similar to updating in the sense that you first need to isolate the record you want to get rid of and then use the delete() method on it.

Post::where('author', 'John Doe')->delete();

Essential query methods

Basic CRUD is a good start, but real-world apps require more nuanced queries. Laravel’s MongoDB integration supports a wide range of methods to refine results.

Before diving in, remember:

  • The -> operator chains methods. Each call returns a query builder, letting you keep adding conditions.
  • The query isn’t executed until you call a terminal method like get(), first(), or count().

Filtering with where()

You've already seen the where() method in the previous section—it's the most commonly used filter:

$posts = Post::where('status', 'published')->get();

Here, status is the field and published is the value. Adding get() at the end actually executes the query.

You can further refine your selection by chaining conditions:

$posts = Post::where('status', 'published')
->where('author', 'Jane Doe')
->get();

This narrows results to documents that satisfy both conditions.

Using orWhere()

When you want to allow alternatives, use orWhere():

$posts = Post::where('status', 'draft')
->orWhere('author', 'Admin')
->get();

This fetches posts that are either still drafts or authored by Admin.

Matching arrays with whereIn() and whereNotIn()

Scenarios where you want to filter by a collection of values instead of a single one are very common. You can do that as follows:

$posts = Post::whereIn('status', ['draft','review'])->get();
$excluded = Post::whereNotIn('author', ['Spammer1','Spammer2'])->get();

Range queries with whereBetween()

Range queries are common for dates and numbers. Say, for example, you want to get all the values created within a certain date range, or all transactions that have the value within a certain range:

$recent = Post::whereBetween('created_at', [now()->subMonth(), now()])->get();

This returns posts created in the last 30 days, useful for analytics or reports. Here, now() is a helper function provided by Laravel. It returns the current time as a Carbon instance.

Sorting with orderBy()

The orderBy() method is the most common way of sorting records based on a given parameter.

$posts = Post::where('status','published')
->orderBy('created_at','desc')
->get();

Chaining multiple orderBy() calls is also possible:

$posts = Post::orderBy('author')
->orderBy('created_at','desc')
->get();

Limiting and skipping results

Paginating a large volume of data is essential to keep your applications running. Usually, it's a combination of the skip() and limit() calls:

$posts = Post::orderBy('created_at','desc')
->skip(10)
->take(5)
->get();

This skips the first 10 and returns the next five. It’s useful when implementing “Load more” or page-based navigation. You can also use the paginate() function provided by Laravel for paginating records. Feel free to consult the official documentation on the topic.

Combining conditions

In most cases, you'll have to combine multiple of the aforementioned methods to get your desired data.

$posts = Post::where('status','published')
->whereIn('author',['Jane Doe','Admin'])
->whereBetween('created_at',[now()->subDays(30), now()])
->orderBy('created_at','desc')
->limit(10)
->get();

You can do a lot more by utilizing the different methods available through the Eloquent ORM or the Laravel query builder. I'd highly recommend you go through the respective documentations to learn more.


Advanced querying techniques

Once you’re comfortable with simple filters, it’s time to explore more powerful querying features that MongoDB enables. Unlike SQL, MongoDB documents can contain arrays, nested objects, or optional fields, and Laravel gives you tools to query them effectively.

Embedded documents

MongoDB allows documents to embed other documents. For example, a Post may contain an array of comments:

{
"title": "First Post",
"comments": [
{ "author": "Alice", "text": "Nice!" },
{ "author": "Bob", "text": "Thanks for sharing." }
]
}

To query posts that have a comment by Alice:

$posts = Post::where('comments.author', 'Alice')->get();

Here, the dot notation checks the author of each comment to find the ones by Alice.

Querying nested arrays

You can also target a specific index of an array:

$posts = Post::where('comments.0.author', 'Alice')->get();

This checks the author field from the first comment of the comments array.

Field existence and null checks

MongoDB documents may not all share the same fields. Laravel lets you query for presence or absence:

$featured = Post::where('featured','exists',true)->get();
$missing = Post::whereNull('featured')->get();
$present = Post::whereNotNull('featured')->get();

For example, here, the first where() query will find the posts where featured exists and is true. In the second one, whereNull() will find the ones where featured is set to null. Finally, in the third one, whereNotNull() will find the posts where featured is anything but not null.

Relationships with whereHas()

If you model related collections, you can query based on relationships. Suppose each post references an authorProfile:

$posts = Post::whereHas('authorProfile', function ($q) {
$q->where('reputation','>',100);
})->get();

This query will get all posts that have an authorProfile related to them and then filter the posts by reputation value higher than 100. You can learn in-depth about Eloquent relationships from the official documentation.

Date queries

The whereDate(), whereMonth(), whereDay(), whereYear(), and whereTime() clauses allow you to filter data by date, month, day, year, or even a specific time. Consult the official documentation for further reading.

Regular expression queries

MongoDB supports regex queries, which Laravel exposes:

$posts = Post::where('title','regexp','/^How to/')->get();
$posts = Post::where('title','regexp','/mongodb/i')->get();

Regex is powerful but be mindful of performance—indexes aren’t always used effectively with leading wildcards.

Chaining complex conditions

In real-world applications, advanced queries often combine all these clauses into one long call:

$posts = Post::where('status','published')
->where('comments.author','Alice')
->whereNotNull('featured')
->whereYear('created_at',2025)
->orderBy('created_at','desc')
->get();

Mastering aggregation pipelines

Basic queries work well for lookups and filtering, but MongoDB’s aggregation framework unlocks advanced analytics and transformations directly in the database. Think of it as a data-processing pipeline: Each stage transforms documents and passes them along to the next stage. This section assumes the reader has familiarity with aggregation in MongoDB official documentation.

Common stages (at a glance)

  • $match: filters documents (like where)
  • $group: groups documents and performs calculations (count, sum, avg)
  • $sort: orders the results
  • $project: selects or reshapes fields
  • $lookup: joins another collection
  • $unwind: splits arrays into multiple documents

Example 1: Count posts by category

$results = Post::raw(function($c) {
return $c->aggregate([
['$group' => ['_id' => '$category','count' => ['$sum' => 1]]],
['$sort' => ['count' => -1]]
]);
});

Divided into two stages, this query first groups all posts by their category field and counts them using $sum: 1. Then, it sorts the results in descending order based on the count, thanks to -1. This helps you quickly see which categories have the most content.

Example 2: Most active authors

$results = Post::raw(function($c) {
return $c->aggregate([
['$group' => ['_id' => '$author','post_count' => ['$sum' => 1]]],
['$sort' => ['post_count' => -1]]
]);
});

Here, post_count represents how many posts each author has created. The $group stage aggregates by author, and $sum: 1 counts one per post. The $sort stage orders the authors by activity level. This query is useful for detecting active contributors, building leaderboards, or analyzing engagement.

Example 3: Monthly post statistics

$results = Post::raw(function($c) {
return $c->aggregate([
['$group' => [
'_id' => [
'year' => ['$year' => '$created_at'],
'month' => ['$month' => '$created_at']
],
'count' => ['$sum' => 1]
]],
['$sort' => ['_id.year' => 1,'_id.month' => 1]]
]);
});

This query groups all posts by their creation year and month. The $group stage creates a unique key for each year-month combination and counts how many posts fall into that bucket using $sum: 1.

The $sort stage then orders the results chronologically by year and month in ascending order. The final result is a list of documents where each entry represents a month (with its year) and the total number of posts created during that month. This produces time-series data suitable for generating charts and trend reports.

Example 4: Joining with $lookup

The $lookup stage is used for performing joins between collections in MongoDB. It allows you to combine documents from different collections into one, similar to SQL JOINs.

$results = Post::raw(function($c) {
return $c->aggregate([
['$lookup' => [
'from' => 'authors',
'localField' => 'author_id',
'foreignField' => '_id',
'as' => 'author_info'
]]
]);
});

Each post will include an author_info array with details. The $lookup stage works like a SQL JOIN—it pulls in matching documents from the authors collection based on the author_id field in the posts. The localField is the field in the current collection (author_id), and foreignField is the field in the related collection (_id). The joined results are placed into a new array field called author_info.

This allows you to enrich each post with additional information about its author without making multiple queries in your Laravel code. This avoids multiple round-trips in code by fetching the author details in the same database query rather than issuing separate queries for each post. That means less network overhead, fewer database calls, and better overall performance, especially when you are dealing with large collections or rendering data-heavy pages.

Example 5: Breaking apart arrays with $unwind

The $unwind stage is used to deconstruct an array field from the documents and output a document for each element. It’s especially useful when you want to analyze or aggregate data stored inside arrays.

$results = Post::raw(function($c) {
return $c->aggregate([
['$unwind' => '$tags'],
['$group' => ['_id' => '$tags','count' => ['$sum' => 1]]]
]);
});

Here’s what happens in this query step by step:

  • The $unwind stage deconstructs the tags array from each post. If a post has three tags, it will now appear as three separate documents, one for each tag.
  • The $group stage then groups these documents by the tag value (_id is set to the tag) and uses $sum: 1 to count how many times each tag appears across all posts.

The final result is a list where each tag is represented once, along with a count of how often that tag occurs in your posts collection. This is very useful when you want to build tag clouds, popularity rankings, or filter options based on tag frequency.

Example 6: Multi-stage pipelines

$results = Post::raw(function($c) {
return $c->aggregate([
['$match' => ['status' => 'published']],
['$unwind' => '$tags'],
['$group' => ['_id' => '$tags','count' => ['$sum' => 1]]],
['$sort' => ['count' => -1]],
['$limit' => 5]
]);
});

This finds the top five tags used in published posts. Here’s how it works:

  • The $match stage first filters the documents so only published posts are considered.
  • The $unwind stage breaks apart the tags array so each tag becomes its own document.
  • The $group stage groups by the tag value and counts occurrences using $sum: 1.
  • The $sort stage then orders the results by the count in descending order.
  • Finally, the $limit stage restricts the results to the top five tags.

The result is a clear snapshot of the most popular tags in your published posts—something you can use to generate trending topics, sidebar widgets, or tag recommendation features.

Performance optimization with indexing

As collections grow, query performance depends heavily on indexes. Without indexes, MongoDB must scan every document in the collection, which quickly becomes inefficient.

Why indexes matter

Indexes act like a book’s table of contents. Instead of flipping through every page, MongoDB jumps straight to relevant entries. Proper indexing can reduce query times from seconds to milliseconds.

But indexes come with trade-offs:

  • They consume memory.
  • They slow down inserts and updates (since each index must also be updated).

Types of indexes

  • Single-field index: speeds up queries on one field
  • Compound index: covers multiple fields, important for queries with combined filters
  • Text index: enables full-text search in string fields
  • Geospatial index: supports queries on coordinates and maps

Creating indexes in Laravel

Indexes can be added in migrations using the schema builder:

Schema::connection('mongodb')->table('posts', function ($collection) {
$collection->index('author'); // single field
});
 
Schema::connection('mongodb')->table('posts', function ($collection) {
$collection->index(['status' => 1, 'created_at' => -1]); // compound
});
 
Schema::connection('mongodb')->table('posts', function ($collection) {
$collection->text(['title', 'body']); // text search
});

Here, 1 means ascending and -1 means descending order.

Monitoring index usage

MongoDB provides explain() to show how a query is executed:

$explain = Post::where('author','Jane Doe')->explain();

Check if the output shows an IXSCAN (index scan) instead of COLLSCAN (collection scan). If you see COLLSCAN, it means the query didn’t use an index.

Best practices

When designing indexes, keep these practices in mind to balance speed and efficiency:

  • Index fields used in frequent filters and sorts: Queries that run often, especially those inside where() or orderBy(), should be backed by indexes for faster lookups.
  • Use compound indexes wisely: Put the most selective field first so MongoDB can narrow down results quickly.
  • Avoid over-indexing: Each index consumes memory and slows down write operations (inserts, updates, deletes). Only create indexes that support actual query patterns.
  • Review regularly with explain(): Queries evolve as applications grow. Use explain() to check if your queries are hitting the right indexes or falling back to collection scans.
  • Match indexes to real workloads: Monitor slow queries in production and add indexes where they have the most impact.

Following these practices will help you maintain a balance between query speed and database health as your collections grow.

Real-world query patterns

Full-text search

Full‑text search lets you search text fields for words or phrases instead of exact string matches. MongoDB uses a text index to break content into tokens and find relevant matches. This is perfect for search bars where users type a few keywords and expect useful results.

Start by creating a text index on the fields you want to search:

Schema::connection('mongodb')->table('posts', function ($c) {
$c->text(['title', 'body']);
});

Then, run a text search. Below, $term is whatever the user typed (e.g., from ?q= in the URL). We first limit to published posts, then apply text matching to title and body:

$term = request('q');
$results = Post::where('status', 'published')
->whereRaw(['$text' => ['$search' => $term]])
->orderBy('created_at','desc')
->limit(20)
->get();

Explanation:

  • The text index enables efficient keyword matching across title and body.
  • where('status','published') narrows the dataset before searching.
  • The where(..., 'text', $term)/orWhere(..., 'text', $term) clauses perform full‑text matches on each field.
  • We keep the result set small (limit(20)) and show the newest first.

This powers search bars in blogs, news sites, or documentation platforms.

Efficient pagination

Efficient pagination is critical when working with large collections. Loading thousands of documents at once can overwhelm your app and the database. Pagination helps you fetch smaller chunks of data for each request, keeping performance stable and user experience smooth.

Large collections need efficient paging. Offset-based pagination works by skipping a certain number of records and then taking the next batch, using methods like skip() and take(). With offset-based pagination:

$page = max((int) request('page',1), 1);
$perPage = 20;
$results = Post::where('status','published')
->orderBy('created_at','desc')
->skip(($page-1)*$perPage)
->take($perPage)
->get();

For high-traffic feeds, cursor-based pagination is better. Instead of skipping records, it uses a pointer (like the last retrieved document’s ID) to fetch the next set. This approach is more efficient at scale and avoids the performance issues that come with large skip values:

$results = Post::where('_id','<',$lastId)
->orderBy('_id','desc')
->take(20)
->get();

Faceted filtering (UGC feeds)

Faceted filtering is when you let users narrow down a dataset using multiple filters at once, such as author, tags, or date ranges. This approach is common in user-generated content feeds and e-commerce apps. User-generated feeds often combine filters:

$query = Post::query()->where('status','published');
 
if ($author = request('author')) {
$query->where('author',$author);
}
if ($tags = request('tags')) {
$query->whereIn('tags',$tags);
}
if ($from = request('from')) {
$query->where('created_at','>=',$from);
}
if ($to = request('to')) {
$query->where('created_at','<=',$to);
}
 
$posts = $query->orderBy('created_at','desc')->take(50)->get();

This supports multi-filter feeds with minimal code. Each condition is optional and only applied if the user provides that filter, so you can combine them in different ways. This pattern is flexible enough to handle search pages, dashboards, or e-commerce filters without requiring separate queries for every possible combination.

Analytics dashboards

Dashboards often need aggregated data to show trends and insights. For example, you might want to show monthly counts of published posts in a chart or report. This kind of aggregation makes it easy for admins to see how content production changes over time:

$monthly = Post::raw(fn($c) => $c->aggregate([
['$match' => ['status'=>'published']],
['$group' => [
'_id' => [
'y' => ['$year' => '$created_at'],
'm' => ['$month' => '$created_at']
],
'count' => ['$sum' => 1]
]],
['$sort' => ['_id.y'=>1,'_id.m'=>1]]
]));

You can transform this output into chart-friendly arrays (e.g., labels = YYYY-MM, series = count) and feed it to your frontend (Chart.js, Highcharts, etc.). In Laravel, cast the cursor to an array/collection, map each item to a { label, value } shape, and render time‑series charts that show month‑over‑month trends for admins.

Joining reference data

To enrich posts with author details, you can pull related author metadata (name, avatar, bio) from a separate authors collection at query time. In MongoDB, this is done with the $lookup stage, which joins matching author documents onto each post as an author_info array. This avoids extra round‑trips from PHP and returns self‑contained results you can render directly:

$withAuthors = Post::raw(fn($c) => $c->aggregate([
['$match'=>['status'=>'published']],
['$lookup'=>[
'from'=>'authors',
'localField'=>'author_id',
'foreignField'=>'_id',
'as'=>'author_info'
]],
['$project'=>[
'title'=>1,
'created_at'=>1,
'author'=>['$arrayElemAt'=>['$author_info.name',0]]
]]
]));

This fetches the author details in the same database query rather than issuing separate queries for each post. That means less network overhead, fewer database calls, and better overall performance, especially when you are dealing with large collections or rendering data-heavy pages.

Caching hot queries

For expensive queries that power high-traffic pages, it’s often best to combine them with caching. Caching stores the results of a heavy query so you don’t need to hit the database every time. This reduces database load and speeds up response times dramatically:

$posts = Cache::remember('home:posts', now()->addMinutes(5), function(){
return Post::where('status','published')
->orderBy('created_at','desc')
->limit(20)
->get();
});

The cache facade is provided by Laravel, and you can learn more about this from the official documentation.

Practical example: Dashboard metrics

Here’s a complete pipeline that filters published posts, groups them by month and author, joins author details, and returns a tidy shape for charts:

$dashboard = Post::raw(fn($c) => $c->aggregate([
// 1) Only consider published posts
['$match' => ['status' => 'published']],
 
// 2) Group by year, month, and author_id; count posts per bucket
['$group' => [
'_id' => [
'y' => ['$year' => '$created_at'],
'm' => ['$month' => '$created_at'],
'author_id' => '$author_id'
],
'post_count' => ['$sum' => 1]
]],
 
// 3) Join author metadata from the authors collection
['$lookup' => [
'from' => 'authors',
'localField' => '_id.author_id',
'foreignField' => '_id',
'as' => 'author'
]],
 
// 4) Project a clean shape for the frontend (drop _id)
['$project' => [
'year' => '$_id.y',
'month' => '$_id.m',
'author' => ['$arrayElemAt' => ['$author.name', 0]],
'post_count' => 1,
'_id' => 0
]],
 
// Optional: sort chronologically, then by post_count
['$sort' => ['year' => 1, 'month' => 1, 'post_count' => -1]]
]));

How it works:

  • $match keeps only published posts to reduce work later in the pipeline.
  • $group buckets documents by (year, month, author_id) and computes a post_count for each bucket.
  • $lookup pulls in the matching author document so we can display the author’s name without extra queries from PHP.
  • $project reshapes the data into fields your frontend can use directly (year, month, author, post_count) and hides the internal _id.
  • $sort (optional) orders the results for charts or tables.

Example output document:

{ "year": 2025, "month": 8, "author": "Jane Doe", "post_count": 12 }

This single pipeline feeds ready‑to‑use data to your charts and reports without writing complex PHP loops.

Testing your MongoDB queries

Correct queries are essential, but so is confidence that they’ll behave consistently as your app evolves. Testing helps catch regressions, verify edge cases, and even highlight performance issues before they hit production.

Test database configuration

Use a separate database for tests. In phpunit.xml, override the environment variables:

<php>
<env name="DB_CONNECTION" value="mongodb"/>
<env name="MONGODB_URI" value="mongodb+srv://<user>:<pass>@cluster0.mongodb.net"/>
<env name="MONGODB_DATABASE" value="laravel_mongo_test"/>
</php>

This ensures test data never touches development or production. You can point it to a local MongoDB instance or a disposable Atlas cluster.

To keep the test DB clean, truncate collections in setUp() or use Laravel’s refreshing traits.

Testing basic queries

Example feature test:

public function test_it_filters_published_posts()
{
Post::insert([
['title'=>'A','status'=>'published','created_at'=>now()],
['title'=>'B','status'=>'draft','created_at'=>now()],
]);
 
$results = Post::where('status','published')->get();
 
$this->assertCount(1, $results);
$this->assertEquals('A', $results->first()->title);
}

This verifies that drafts are excluded.

Testing aggregations

You can also assert on transformed results:

public function test_monthly_counts()
{
Post::insert([
['title'=>'P1','status'=>'published','created_at'=>now()->startOfMonth()],
['title'=>'P2','status'=>'published','created_at'=>now()->startOfMonth()],
['title'=>'D1','status'=>'draft','created_at'=>now()->startOfMonth()],
]);
 
$pipeline = [
['$match'=>['status'=>'published']],
['$group'=>['_id'=>['y'=>['$year'=>'$created_at'],'m'=>['$month'=>'$created_at']],'count'=>['$sum'=>1]]]
];
 
$results = Post::raw(fn($c)=>$c->aggregate($pipeline));
$data = iterator_to_array($results);
 
$this->assertEquals(1, count($data));
$this->assertEquals(2, $data[0]['count']);
}

This ensures only published posts are counted.

Mocking and isolation

For unit tests that check query composition without hitting the DB, mock the model:

$mock = Mockery::mock(Post::class);
$mock->shouldReceive('where')->with('status','published')->andReturnSelf();
$mock->shouldReceive('orderBy')->with('created_at','desc')->andReturnSelf();
$mock->shouldReceive('limit')->with(10)->andReturnSelf();
$mock->shouldReceive('get')->andReturn(collect([]));

This isolates application logic from database behavior.

Performance spot-checks

Use explain() in tests to confirm index usage:

public function test_author_query_uses_index()
{
Post::insert([
['title'=>'A','author'=>'Jane','status'=>'published'],
['title'=>'B','author'=>'John','status'=>'published'],
]);
 
$explain = Post::where('author','Jane')->explain();
 
$this->assertStringContainsString('IXSCAN', json_encode($explain));
}

This guards against accidental collection scans.

Conclusion

We’ve covered a lot of ground—from configuring Laravel with MongoDB, to basic CRUD operations, advanced query methods, aggregation pipelines, indexing strategies, real-world query patterns, and testing approaches.

Key takeaways

  • Setup: The mongodb/laravel-mongodb package integrates MongoDB seamlessly into Laravel. Testing with Tinker or closure routes confirms your setup quickly.
  • Fundamentals: CRUD operations in MongoDB feel very similar to SQL in Laravel thanks to Eloquent and the query builder.
  • Essential queries: Methods like where, orWhere, whereIn, and orderBy give you fine-grained control over filtering and sorting.
  • Advanced techniques: Dot notation, existence checks, regex, and date helpers make it easy to query nested or optional fields.
  • Aggregation pipelines: This is the most powerful way to handle analytics, joins, and reshaping data within MongoDB itself.
  • Indexing: Proper indexes can make or break performance; use explain() to validate.
  • Real-world patterns: Search, pagination, dashboards, and caching show how these tools power practical app features.
  • Testing: A dedicated test database, clear factories, and assertions on explain() help ensure correctness and performance.

In conclusion, MongoDB and Laravel together provide a robust foundation for building modern applications. You’ve seen how the basics like CRUD operations align closely with SQL workflows, while advanced techniques such as aggregations and indexing unlock powerful capabilities unique to MongoDB.

By combining these features with Laravel’s expressive syntax, you can write code that is both clean and scalable. Whether you are working on a simple blog or a complex analytics dashboard, this stack gives you the flexibility to adapt as your data and requirements grow.

Farhan Hasin Chowdhury photo

Backend engineer and technical author teaching millions through code and content.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Battle Ready Laravel

The ultimate guide to auditing, testing, fixing and improving your Laravel applications so you can build better apps faster and with more confidence.

Visit Battle Ready Laravel
Curotec logo

Curotec

World class Laravel experts with GenAI dev skills. LATAM-based, embedded engineers that ship fast, communicate clearly, and elevate your product. No bloat, no BS.

Curotec
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $3200/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Cut PHP Code Review Time & Bugs into Half with CodeRabbit logo

Cut PHP Code Review Time & Bugs into Half with CodeRabbit

CodeRabbit is an AI-powered code review tool that specializes in PHP and Laravel, running PHPStan and offering automated PR analysis, security checks, and custom review features while remaining free for open-source projects.

Cut PHP Code Review Time & Bugs into Half with CodeRabbit
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit

The latest

View all →
Laravel 12.44 Adds HTTP Client afterResponse() Callbacks image

Laravel 12.44 Adds HTTP Client afterResponse() Callbacks

Read article
Handle Nested Data Structures in PHP with the Data Block Package image

Handle Nested Data Structures in PHP with the Data Block Package

Read article
Detect and Clean Up Unchanged Vendor Files with Laravel Vendor Cleanup image

Detect and Clean Up Unchanged Vendor Files with Laravel Vendor Cleanup

Read article
Seamless PropelAuth Integration in Laravel with Earhart image

Seamless PropelAuth Integration in Laravel with Earhart

Read article
Laravel API Route image

Laravel API Route

Read article
Laravel News 2025 Recap image

Laravel News 2025 Recap

Read article