Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 Comment System with Nested Replies Using Eloquent

๐Ÿ› ๏ธ Laravel Tutorials5 min read

Laravel 13 Comment System with Nested Replies Using Eloquent

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 12, 2026
Laravel 13 Comment System with Nested Replies Using Eloquent

To build a comment system with replies in Laravel 13, give the comments table a nullable parent_id that points back to the same table, define replies() as a hasMany on that column, and load top-level comments with their replies eagerly. A small recursive Blade partial then renders any depth of thread with a fixed number of queries.

The example attaches comments to blog posts, lets signed-in users reply, and limits nesting to keep threads readable.

Prerequisites

  • Laravel 13 with PHP 8.3 or newer.
  • An existing Post model and authenticated users.
  • Basic knowledge of Eloquent relationships and form requests.

Step 1: Create the comments table

Code
php artisan make:model Comment -m
JavaScript
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('post_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete();
            $table->text('body');
            $table->timestamps();

            $table->index(['post_id', 'parent_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('comments');
    }
};

The parent_id foreign key references comments itself. A top-level comment has parent_id = null; a reply stores the id of the comment it answers. Deleting a comment removes its replies too.

Step 2: Define the relationships

JavaScript
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Comment extends Model
{
    protected $fillable = ['post_id', 'user_id', 'parent_id', 'body'];

    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function parent(): BelongsTo
    {
        return $this->belongsTo(Comment::class, 'parent_id');
    }

    public function replies(): HasMany
    {
        return $this->hasMany(Comment::class, 'parent_id')->oldest();
    }
}

On the Post model, add a relationship that returns only top-level comments:

JavaScript
public function comments(): HasMany
{
    return $this->hasMany(Comment::class)->whereNull('parent_id')->latest();
}

Step 3: Load comments without N+1 queries

If you call $comment->replies inside a loop without eager loading, every comment triggers a new query. Load the levels you display up front with nested dot notation:

JavaScript
public function show(Post $post)
{
    $post->load([
        'comments.author',
        'comments.replies.author',
        'comments.replies.replies.author',
    ]);

    return view('posts.show', compact('post'));
}

This example supports three levels: a comment, a reply, and a reply to a reply. Each level costs a fixed number of queries no matter how many comments exist. Unlimited depth sounds attractive, but deep threads are hard to read on mobile and hard to load efficiently, so I cap nesting in the store logic below.

Step 4: Store comments and replies

JavaScript
<?php

namespace App\Http\Controllers;

use App\Models\Comment;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class CommentController extends Controller
{
    public function store(Request $request, Post $post): RedirectResponse
    {
        $data = $request->validate([
            'body' => ['required', 'string', 'max:2000'],
            'parent_id' => [
                'nullable',
                Rule::exists('comments', 'id')->where('post_id', $post->id),
            ],
        ]);

        $parentId = $data['parent_id'] ?? null;

        if ($parentId) {
            $parent = Comment::with('parent.parent')->findOrFail($parentId);

            // Replies to the third level attach to their parent instead of nesting deeper.
            if ($parent->parent?->parent) {
                $parentId = $parent->parent_id;
            }
        }

        $post->comments()->create([
            'user_id' => $request->user()->id,
            'parent_id' => $parentId,
            'body' => $data['body'],
        ]);

        return back()->with('status', 'Comment posted.');
    }
}

The exists rule includes a where on post_id, so nobody can attach a reply to a comment that belongs to a different post. Note that $post->comments()->create() sets post_id automatically.

CSS
Route::post('/posts/{post}/comments', [CommentController::class, 'store'])
    ->middleware(['auth', 'throttle:10,1'])
    ->name('comments.store');

The throttle middleware limits each user to ten comments per minute, a cheap defence against spam bursts.

Step 5: Render threads with a recursive partial

Create resources/views/comments/_comment.blade.php:

JavaScript
<div class="comment comment-depth-{{ $depth }}">
    <p><strong>{{ $comment->author->name }}</strong> ยท {{ $comment->created_at->diffForHumans() }}</p>
    <p>{{ $comment->body }}</p>

    @auth
        <form method="POST" action="{{ route('comments.store', $comment->post_id) }}">
            @csrf
            <input type="hidden" name="parent_id" value="{{ $comment->id }}">
            <textarea name="body" rows="2" required></textarea>
            <button type="submit">Reply</button>
        </form>
    @endauth

    @if ($comment->relationLoaded('replies'))
        @foreach ($comment->replies as $reply)
            @include('comments._comment', ['comment' => $reply, 'depth' => $depth + 1])
        @endforeach
    @endif
</div>

In the post view, loop over top-level comments with @include('comments._comment', ['comment' => $comment, 'depth' => 0]). The relationLoaded check stops the partial from triggering lazy queries at the deepest level. Blade's {{ }} escapes the comment body, which protects you from stored XSS.

Common mistakes

  • Lazy loading in loops. Turn on Model::preventLazyLoading(! app()->isProduction()) in AppServiceProvider to catch missing eager loads during development.
  • Counting replies with ->replies->count(). Use withCount('replies') when you only need the number.
  • Rendering comment HTML with {!! !!}. Never output user input unescaped.
  • Trusting parent_id from the form. Always validate that it exists and belongs to the same post.

How do you verify it works?

Post a comment, reply to it, then reply to the reply. Check the comments table: each reply should hold the correct parent_id. A fourth-level reply should attach to the third-level comment's parent. Install Laravel Debugbar or Telescope locally and confirm the query count stays flat when you add more comments.

How do you show reply counts and the newest activity?

Lists of posts often show "12 comments" without loading the comments themselves. Use withCount on the list query: Post::withCount('comments')->paginate(20) adds a comments_count attribute with one extra subquery. Because the comments relationship filters on parent_id, that number counts top-level comments only. Add a separate allComments() relationship without the filter if you want the total including replies.

For moderation, add a nullable approved_at column and a local scope such as scopeApproved(), then apply it in both the top-level and the replies relationships so unapproved replies never appear inside approved threads.

FAQ

Should I use a polymorphic relationship instead?

Use morphMany if comments must attach to several models such as posts, videos and products. The parent_id pattern works the same way on top.

How do I paginate comments with replies?

Paginate the top-level query, for example $post->comments()->with('replies.author')->paginate(20), and load replies only for that page.

How do I let users delete their own comments?

Create a CommentPolicy with a delete method that compares user_id, and call Gate::authorize('delete', $comment) in the controller.

References

Article Index & Topic Tags
LaravelEloquentRelationshipsComments

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.