Building a Threaded Comments System with Eloquent Adjacency Lists
Threaded replies, where a comment can reply to another comment, which can reply to another, are a classic case for the adjacency list pattern: each row just points to its own parent. It is simple to model and, with a couple of Eloquent tricks, simple to query efficiently too.
Step 1: The migration
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('article_id')->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete();
$table->foreignId('user_id')->constrained();
$table->text('body');
$table->timestamps();
});
parent_id references the same table, comments, which is what makes this an adjacency list: every row knows its immediate parent, and nothing more.
Step 2: The Comment model
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 = ['article_id', 'parent_id', 'user_id', 'body'];
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(Comment::class, 'parent_id')->with('replies');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
Notice replies() eager loads itself recursively with ->with('replies'). This is the trick that lets a single query kick off a full nested tree load instead of writing a manual recursive loader.
Step 3: Loading a full thread
$comments = Comment::where('article_id', $article->id)
->whereNull('parent_id')
->with(['author', 'replies.author', 'replies.replies.author'])
->latest()
->get();
For most discussion threads, two or three levels of eager-loaded nesting covers the realistic depth people actually reply to. If you need arbitrary depth, Eloquent's recursive with('replies') on the model itself will keep following the chain, though at very deep nesting you'll want to cap it or switch to a nested set model instead.
Step 4: Rendering the tree recursively in Blade
{{-- resources/views/comments/_comment.blade.php --}}
<div class="comment" style="margin-left: {{ $depth * 24 }}px">
<strong>{{ $comment->author->name }}</strong>
<p>{{ $comment->body }}</p>
<form action="{{ route('comments.store', $comment->article_id) }}" method="POST">
@csrf
<input type="hidden" name="parent_id" value="{{ $comment->id }}">
<textarea name="body"></textarea>
<button type="submit">Reply</button>
</form>
@foreach ($comment->replies as $reply)
@include('comments._comment', ['comment' => $reply, 'depth' => $depth + 1])
@endforeach
</div>
{{-- resources/views/comments/index.blade.php --}}
@foreach ($comments as $comment)
@include('comments._comment', ['comment' => $comment, 'depth' => 0])
@endforeach
Step 5: Storing a reply
namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Comment;
use Illuminate\Http\Request;
class CommentController extends Controller
{
public function store(Request $request, Article $article)
{
$validated = $request->validate([
'body' => ['required', 'string', 'max:2000'],
'parent_id' => ['nullable', 'exists:comments,id'],
]);
$article->comments()->create([
...$validated,
'user_id' => $request->user()->id,
]);
return back();
}
}
Step 6: Deleting a comment without orphaning replies
The cascadeOnDelete() on parent_id in the migration means deleting a parent comment also deletes its entire reply chain automatically at the database level. If you would rather keep replies visible under a "[deleted]" placeholder instead of cascading, swap the delete for a soft delete and check for a deleted_at timestamp when rendering.
Wrapping up
The adjacency list pattern keeps the schema dead simple, one nullable self-referencing foreign key, while Eloquent's recursive eager loading handles the hard part of assembling a nested tree without N+1 queries at every depth. For comment threads and most real-world nesting depths, this is a much lighter lift than a full nested set or materialized path implementation.