Introduction
As web applications evolve, so does the way we handle data. Traditional relational databases (MySQL, PostgreSQL) are great for structured data, but modern apps — think Trello boards, job boards like Joobaz, or analytics platforms like Bouklink — often need nested, flexible data structures that grow dynamically.
That’s where MongoDB shines. Instead of rows and tables, it stores JSON-like documents that can include arrays, subdocuments, and embedded relationships.
Pair that with Laravel 12, and you get a dream stack:
✅ Laravel’s expressive Eloquent syntax and validation
✅ MongoDB’s schema flexibility and performance
✅ A single clean PHP API to manage complex, nested data
In this deep dive, we’ll build a Project Management System using Laravel 12 and MongoDB — showing you how to embed and reference data the right way.
Step 1 : Environment Setup
1. Install the MongoDB package
composer require mongodb/laravel-mongodb
This official package (formerly jenssegers/laravel-mongodb) provides a MongoDB-aware Eloquent driver and Schema Builder.
2. Update your .env
DB_CONNECTION=mongodb
MONGODB_URI=mongodb://127.0.0.1:27017
MONGODB_DATABASE=laravel_nested_demo
3. Configure config/database.php
'mongodb' => [
'driver' => 'mongodb',
'dsn' => env('MONGODB_URI'),
'database' => env('MONGODB_DATABASE', 'laravel_nested_demo'),
],
That’s it — Laravel now speaks MongoDB as fluently as it does MySQL.
Step 2 : Understanding the Data Model
We’ll build a hierarchy like this:
User
├── Projects (one-to-many)
├── Tasks (embedded)
├── Comments (embedded)
- Each User owns multiple Projects.
- Each Project embeds Tasks directly inside its document.
- Each Task embeds Comments, but each comment only references a user by ID — to always display live user info.
Step 3 : User Model (Referenced)
User data (name, avatar, role) changes frequently, so embedding would create duplicates.
Instead, store users in their own collection and reference them via user_id.
namespace App\Models;
use MongoDB\Laravel\Eloquent\Model;
class User extends Model
{
protected $connection = 'mongodb';
protected $collection = 'users';
protected $fillable = ['name', 'email', 'avatar', 'role'];
}
Example document:
{
"_id": "u1",
"name": "Medali Bouk",
"email": "[email protected]",
"avatar": "/img/avatars/medali.jpg",
"role": "admin"
}
Step 4 : Project Model (Embedding Tasks)
Projects and their tasks are read together most of the time, so we embed tasks directly inside the project document.
namespace App\Models;
use MongoDB\Laravel\Eloquent\Model;
class Project extends Model
{
protected $connection = 'mongodb';
protected $collection = 'projects';
protected $fillable = ['user_id', 'title', 'description', 'tasks'];
protected $casts = ['tasks' => 'array'];
public function user()
{
return $this->belongsTo(User::class, 'user_id', '_id');
}
}
Example document:
{
"_id": "p1",
"user_id": "u1",
"title": "Bouklink Refactor",
"description": "Rebuilding Bouklink’s core engine",
"tasks": [
{
"_id": "t1",
"title": "Implement Redis buffering",
"status": "done",
"comments": []
}
]
}
Step 5 : Task and Comment Structure
We don’t need separate collections for tasks or comments —> they live entirely inside a project.
When adding comments, we embed them inside the corresponding task, but store only the user_id.
Adding a new task
$project = Project::find($projectId);
$newTask = [
'_id' => Str::uuid(),
'title' => 'Optimize Docker startup order',
'status' => 'in_progress',
'comments' => [],
];
$project->push('tasks', $newTask);
Adding a comment
$newComment = [
'_id' => Str::uuid(),
'user_id' => $user->_id,
'message' => $request->message,
'created_at' => now(),
];
foreach ($project->tasks as &$task) {
if ($task['_id'] === $taskId) {
$task['comments'][] = $newComment;
}
}
$project->update(['tasks' => $project->tasks]);
Step 6 : Rendering Comments with Live User Data
In your Blade view:
@foreach ($project->tasks as $task)
<h5>{{ $task['title'] }}</h5>
@foreach ($task['comments'] as $comment)
@php $user = \App\Models\User::find($comment['user_id']); @endphp
<div class="comment">
<img src="{{ $user->avatar }}" alt="{{ $user->name }}" class="avatar">
<strong>{{ $user->name }}</strong>
<p>{{ $comment['message'] }}</p>
</div>
@endforeach
@endforeach
Now if a user updates their name or photo, every comment updates automatically — no data sync headaches.
Step 7 : Querying Nested Documents
MongoDB’s dot notation makes querying deep fields easy:
// Projects containing pending tasks
$pending = Project::where('tasks.status', 'pending')->get();
// Projects commented on by a specific user
$commented = Project::where('tasks.comments.user_id', $user->_id)->get();
// Find tasks mentioning a keyword
$search = Project::where('tasks.title', 'like', '%Redis%')->get();
Step 8 : Updating Embedded Fields Efficiently
Instead of rewriting the entire array, use the positional $ operator:
Project::where('_id', $projectId)
->where('tasks._id', $taskId)
->update(['tasks.$.status' => 'done']);
This atomic update is fast and prevents race conditions.
Step 9 : Aggregation Pipelines for Analytics
MongoDB’s aggregation framework lets you generate analytics without PHP loops.
Example 1 : Count comments per user
$results = Project::raw(fn($c) => $c->aggregate([
['$unwind' => '$tasks'],
['$unwind' => '$tasks.comments'],
['$group' => [
'_id' => '$tasks.comments.user_id',
'total_comments' => ['$sum' => 1]
]],
['$sort' => ['total_comments' => -1]]
]));
Example 2 : Top projects by task count
$topProjects = Project::raw(fn($c) => $c->aggregate([
['$project' => ['title' => 1, 'task_count' => ['$size' => '$tasks']]],
['$sort' => ['task_count' => -1]],
['$limit' => 5]
]));
Example 3 : Join user details into aggregations
$pipeline = [
['$unwind' => '$tasks'],
['$unwind' => '$tasks.comments'],
['$lookup' => [
'from' => 'users',
'localField' => 'tasks.comments.user_id',
'foreignField' => '_id',
'as' => 'user_info'
]],
['$project' => [
'project_title' => '$title',
'comment_message' => '$tasks.comments.message',
'user_name' => ['$arrayElemAt' => ['$user_info.name', 0]]
]]
];
$commentsWithUsers = Project::raw(fn($c) => $c->aggregate($pipeline));
This single query joins comment and user data efficiently — no N+1 queries.
Step 10 : Indexing and Performance
Without indexes, MongoDB scans every document — disastrous at scale.
Add indexes
Schema::connection('mongodb')->collection('projects', function ($c) {
$c->index('user_id');
$c->index('tasks._id');
$c->index('tasks.comments.user_id');
});
Check usage
$explain = Project::where('tasks.comments.user_id', $userId)->explain();
if (str_contains(json_encode($explain), 'COLLSCAN')) {
logger('⚠️ Index missing on comments.user_id');
}
Indexes are like anchors — they keep queries fast even with millions of documents.
Step 11 : Embed vs Reference: A Quick Guide
| Entity | Strategy | Reason |
|---|---|---|
| Users | Reference | Global, frequently updated |
| Projects | Root document | Primary entity |
| Tasks | Embed | Always read with project |
| Comments | Embed (user reference) | Tight coupling, dynamic users |
| Logs / invoices | Embed snapshot | Immutable historical record |
Rule of thumb: Embed what you read together, reference what you update separately.
Step 12 : Performance Comparison
| Operation | SQL (Eloquent MySQL) | MongoDB Embedded | Improvement |
|---|---|---|---|
| Fetch project with tasks + comments | 3–4 JOINs | 1 read | ≈ 4× faster |
| Add comment | INSERT + SELECT | Atomic update | ≈ 2× faster |
| Analytics (counts, tags) | Multiple queries | 1 pipeline | ≈ 5× faster |
MongoDB minimizes joins and allows denormalized reads — perfect for content-heavy applications.
Step 13 : Common Pitfalls to Avoid
❌ Over-embedding: Don’t embed thousands of subdocuments. MongoDB has a 16 MB document limit.
✅ Shallow embedding: Keep arrays moderate (100–500 items).
❌ Missing indexes: Even NoSQL needs indexes.
✅ Bulk operations: Use updateMany or pipelines for mass updates.
✅ Cache expensive queries: Use Cache::remember() for heavy aggregations.
Conclusion
Laravel 12 and MongoDB together let you model real-world data — complex, hierarchical, and alive — while keeping the clarity of Eloquent.
Key Takeaways
-
Embed where coupling is strong.
-
Reference where data evolves independently.
-
Use pipelines for analytics, not loops.
-
Index nested fields to stay performant.
-
Keep Eloquent syntax for readability, drop to raw queries when optimizing.
Whether you’re building a social platform, a job board, or a SaaS dashboard, this pattern keeps your schema flexible, your queries clean, and your performance predictable.
Laravel brings the elegance.
MongoDB brings the freedom.
Together, they redefine modern data modeling.