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.