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
Postmodel and authenticated users. - Basic knowledge of Eloquent relationships and form requests.
Step 1: Create the comments table
php artisan make:model Comment -m<?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
<?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:
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:
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
<?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.
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:
<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())inAppServiceProviderto catch missing eager loads during development. - Counting replies with
->replies->count(). UsewithCount('replies')when you only need the number. - Rendering comment HTML with
{!! !!}. Never output user input unescaped. - Trusting
parent_idfrom 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.



