Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

ctrlaltdelme's avatar

Nested Comment System?

For a system that allows nested comments on comments of reviews, what would the DB schemas look like?

I have

reviews review_comments

But what about foreign keys? I just have a review comment with an FK of review_id currently and that makes sense but what about the nesting part?

1 like
30 replies
jj15's avatar

So you want to allow for replies to comments on reviews? This is definitely possible and what's known as a self-referential relationship.

In your review_comments table, you'd define a nullable column parent_comment_id (or parent_id) which stores the ID of the comment being replied to. You could then make the review_id column nullable too since a reply would be related to the review via the parent comment.

As for nesting, this particular setup would allow for infinite nesting. This means you could have replies to replies, similar to what platforms like Reddit allow. However, this can become a bit complicated. Especially when using Livewire, where it's generally recommended to keep nesting to a minimum (if you have each comment as a separate component).

For this, you'd want to implement some sort of check or validation to ensure that replies can only be posted to parent or "top-level" comments. This would yield a system like Laracasts or YouTube (at least from the frontend where replies are only nested at one level).

This basic tutorial/example should give you a general starting point/overview.

1 like
ctrlaltdelme's avatar

@jj15 Fantastic! I'll give that link a look over. Awesome that it's for Laravel 11 too.

You could then make the review_id column nullable too since a reply would be related to the review via the parent comment.

So I would make the PK for the reviews table nullable? Or did you mean the FK to the reviews table on the review_comments table, which would also be review_id lol

Thanks again!

Merklin's avatar

@ctrlaltdelme

Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('user_id');
            $table->unsignedInteger('review_id')->nullable(); 
            $table->unsignedInteger('parent_id')->nullable();
            $table->text('body');
            $table->timestamps();
        });

@jj15 suggested review_id to be nullable, because when adding a reply, it will be linked to the comment instead, so no point to also link to the review. And parent_id is the ID in the comments table.

For the first comment: parent_id is empty and review_id is filled. For the reply (nested comment): parent_id is filled with the ID of the comment, and review_id is empty

P.S.: This is an example schema. You may need to adjust it to your needs. I.e. add fields, change integer to string is you are using UUID.

2 likes
ctrlaltdelme's avatar

@Merklin Thanks! I have this. Does this look alright? I noticed you used unsignedInteger for the other ID fields, but if they're Foreign Keys is it okay to do it this way? Sorry if that's a dumb question. I have a hard time with working through abstract things

        Schema::create('review_comments', function (Blueprint $table) {
            $table->id();
            $table->text('content');
            $table->foreignIdFor(User::class)->constrained('users');
            $table->foreignIdFor(Review::class)->nullable()->constrained('reviews');
            $table->unsignedBigInteger('parent_id')->nullable();
            $table->morphs('commentable');
            $table->timestamps();
        });
Merklin's avatar

@ctrlaltdelme

$table->foreignIdFor(Review::class)->nullable()->constrained('reviews');

If the id column in the Review table is primary i.e. cannot be null, then you cannot use $table->foreignIdFor(Review::class)->nullable(). That's why in the example it is defined as $table->unsignedInteger('review_id')->nullable();

You can also use unsignedBigInteger instead of unsignedInteger

ctrlaltdelme's avatar

@Merklin Ah okay, that makes sense. Does Laravel know to assign the parent_id field automagically or does it need to be manually added into CRUD operations?

EDIT: Actually I re-read several times and maybe I don't understand. Why does the review_id column in the review_comments table have to be nullable? Why can't I use foreignIdFor?

Merklin's avatar

@ctrlaltdelme Because when you add a comment to a comment you will add child to parent and the child doesn't need to know the review_id. It needs to know only the parent_id

1 like
jj15's avatar

@Merklin Thanks for filling in the gaps! I believe foreignIdFor() can be made nullable though since it only affects the local column, or do you have a specific reference for this?

@ctrlaltdelme Laravel will not add the parent_id automatically. You would need to manually pass it. You'll likely create a relationship on your ReviewComment model to fetch replies:

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

Calling this relationship method allows you to access query builder methods like create(). In which case, parent_id will be added for you.

1 like
ctrlaltdelme's avatar

@jj15 Awesome! I think I got it!

Ignore the Polymorphic. I'm trying to reduce how many tables I have for things since I have Movies and TV Series, Seasons and Episodes (still WIP), I want to have one table to rule them all...so to speak.

I can call the method whatever I want right? It doesn't have to be named similar to the Model Class, ya? comments for a Review makes more sense than reviewComments (what Laravel Idea autogenerates).

Then here is the ReviewComment

I swear I always name things more complex than they need to be, but I guess it makes sense to me.

jj15's avatar

@ctrlaltdelme

Nice, those models look good to me :) Is the ReviewComment::commentable() morph relationship left over from previous tinkering? I ask since it appears comments are only related to reviews.

You can indeed name the relationship method whatever you want. For example, I have a project with a User and UserProfile model to keep things split, the relationship method in the User model is simply named profile().

Something to keep in mind though is that Laravel may not always be able to magically determine the relationship name in some cases. Such as when you're using factories:

// Create one review with one like...
Review::factory()
    ->has(ReviewLike::factory(), 'likes') // <- Specify the relationship name...
    ->create();

But I believe you can avoid this by using the magic relationship methods if you wish to instead:

Review::factory()->hasLikes()->create();

Just as an observation: I see your comments() relationship has latest() chained on as per the tutorial. This is fine to keep, but you may wish to remove it and instead apply that constraint where you actually need it, or even extract it to a separate relationship method (e.g. latestComments()).

If you're using MySQL/MariaDB, you should also be aware of its non-deterministic sorting behavior when ordering by columns that might not always be unique (i.e. created_at, updated_at). This was something I never learned about until it eventually bit me.

1 like
ctrlaltdelme's avatar

@jj15

You can indeed name the relationship method whatever you want. For example, I have a project with a User and UserProfile model to keep things split, the relationship method in the User model is simply named profile().

That's what I named mine too! I made mine separate from each other and also named it UserProfile.

Is the ReviewComment::commentable() morph relationship left over from previous tinkering? I ask since it appears comments are only related to reviews.

What do you mean they're only related to reviews? That isn't the intention lol. So, maybe I did something wrong here. The idea is that since I'm working on a TV and Movie tracking site, reviews should be able to be posted to a movie AND TV Show. So, what did I do wrong or do I need to change or add something?

If you're using MySQL/MariaDB, you should also be aware of its non-deterministic sorting behavior when ordering by columns that might not always be unique (i.e. created_at, updated_at). This was something I never learned about until it eventually bit me.

I changed to Postgres recently :D

jj15's avatar

@ctrlaltdelme

What do you mean they're only related to reviews? That isn't the intention lol. So, maybe I did something wrong here. The idea is that since I'm working on a TV and Movie tracking site, reviews should be able to be posted to a movie AND TV Show. So, what did I do wrong or do I need to change or add something?

I say this since each ReviewComment model should only be related to a review or another parent review comment. It doesn't need to have knowledge of the model that's being reviewed. That's the duty of the Review model and it's reviewable() relationship.

If you want to access the reviewable model from the comment itself, you would traverse them like so:

$comment->review->reviewable;

For a reply, it wouldn't be linked to a review but the parent comment, meaning review_id would be null. You'd need to define and access a parent() relationship:

// Models/ReviewComment.php
public function parent(): BelongsTo
{
    return $this->belongsTo(ReviewComment::class, 'parent_id');
}

// Somewhere else...
$comment->parent->review->reviewable;

This is part of a concept known as database normalization. By having a single source of truth (the reviewable() relationship) for the model being reviewed, you eliminate the risk of inconsistent or duplicate data.

To illustrate this, imagine you have a review with the reviewable() relationship linked to a movie. But a comment for that review has its commentable() relationship linked to a TV show. This is possible since you're technically managing two relationships that could point to two different things.

1 like
ctrlaltdelme's avatar

@jj15 Sorry, if this is a dumb question -- so, then it sounds like I don't really need commentable and I end up with this as a final Model.

ctrlaltdelme's avatar

@jj15 To add a ReviewLike to each; a Review and ReviewComment (that makes sense, right), I'd add a relationship to both models? I assume a HasMany?

jj15's avatar

@ctrlaltdelme

Yes, that's correct. Since both reviews and comments can be liked, I'd recommend changing the model name from ReviewLike to Like so it's not a misnomer. You'll also want to ensure you have the proper polymorphic relationship defined, likable().

You could even go further by creating a trait, HasLikes, that defines the likes() relationship and if you wish, convenience methods such as like() and unlike() (just handles the create() and delete() calls). You can then drop this trait into any "likable" model to gain that functionality.

1 like
ctrlaltdelme's avatar

@jj15 Oh that's really cool! I haven't really known what Traits are used for. I have some Scopes and Helper methods (which kind of sound like what you're describing here?), but they're all in the Model.

Can you elaborate more on what this would look like?

jj15's avatar

@ctrlaltdelme

PHP is a single-inheritance language, meaning you can only inherit from one class at a time. For example, every Eloquent model you create inherits the base Illuminate\Database\Eloquent\Model class and its methods.

Traits let you get around this. Like classes, they can have methods and properties inside them. You can then include them in other classes to inherit all of their (public) methods without using or breaking the chain of actual inheritance.

They essentially let you sprinkle small bits of shared and reusable functionality into classes as needed.

You might want to watch this 15-minute video that explains them along with interfaces and abstract classes, two other important things to know about in object-oriented PHP.


In your case, since Review and ReviewComment models can both have likes. You can reuse the same code for the relationship by putting it into a trait:

// app/Models/Traits/Likable.php

namespace App\Models\Traits;

use App\Models\Like;
use Illuminate\Database\Eloquent\Relations\MorphMany;

trait Likable
{
    public function likes(): MorphMany
    {
        return $this->morphMany(Like::class, 'likable');
    }

    public function like(): void
    {
        $this->likes()->create(['user_id' => auth()->user()->id]);
    }

    public function unlike(): void
    {
        $this->likes()->where('user_id', auth()->user()->id)->delete();
    }
}

You can then include it in your likable models:

// app/Models/Review.php

namespace App\Models;

use App\Models\Traits\Likable; // <- Your trait here...
use Illuminate\Database\Eloquent\Model;

class Review extends Model // <- We can only inherit from this one class...
{
    use Likable; // <- Tell your class to use the trait...

    // ...
}

// Somewhere else...

$review->like(); // <- Call the trait's method...

Scopes and helpers aren't traits (just regular methods in your model), but you can put them in traits if there is a need to reuse them across models.

1 like
ctrlaltdelme's avatar

@jj15 Oh that's really helpful! Will definitely help with colocating code into one place and fixing DRY principles I'm breaking.

Started the video but had to attend to parenting things. Will definitely continue later!

1 like
ctrlaltdelme's avatar

@jj15 OK! I'm working on the comments and replies now, trying to hook everything up and I'm at a mental roadblock.

Here is my ReviewCommentsController

class ReviewCommentsController extends Controller
{
    public function store(Request $request, User $user, Movie $movie, Review $review)
    {
        $validated = $request->validate([
            'content' => ['required', 'string', 'min:10'],
            'parent_id' => ['nullable', 'exists:review_comments,id']
        ]);

        $comment = new ReviewComment();
        $comment->content = $validated['content'];
        $comment->user_id = auth()->id();
        $comment->review_id = $review->id;

        if ($request->has('parent_id')) {
            $comment->parent_id = $validated['parent_id'];
        }

        dd($comment);

    }
}

And here is the route definition. I think I'm not following best practices and this may be a little verbose, but it's how I can remember things. I'm open to suggestions for shortening things though:

Route::post('/users/{user}/{movie}/reviews/{review}/comment',
    [ReviewCommentsController::class, 'store'])->name('user.review.comment')->middleware('auth');

And I can confirm it is hooked up properly to the route and form and can dd $validated or $comment and it's all good there. My problem is, I know I only have like...2 fields to set (lol) but when do I set the parent_id and when do I not? Also, why do I have to add the User, Movie and Review models to the route parameters? Is it because I'm expecting them in the route definition?

I know I didn't rename my classes 😆. I didn't want to have Like and Comment because my dumb brain would get confused with what it's specifically for lol

jj15's avatar

@ctrlaltdelme

I don't think it's necessary to bind the Movie model to the route, since you stated reviews can be for either movies or TV shows. You could instead have your definition look something like this:

Route::post('users/{user}/reviews/{review}/comments', [ReviewCommentController::class, 'store'])
    ->middleware('auth')
    ->name('user.reviews.comments.store')
    ->scopeBindings(); <- Ensure the review belongs to the user...

Note that I also singularized the controller name per convention and changed the route name a bit to read better.


You can simplify your controller's store() method a bit like so:

$validated = $request->validate([
    'content' => ['required', 'string', 'min:10'],
    'parent_id' => ['nullable', 'exists:review_comments,id']
]);

$review->comments()->create([
    'content' => $validated['content'],
    'user_id' => auth()->id(),
    'parent_id' => $validated['parent_id'], // <- Will already be `null` if not present, so no need for an extra check...
]);

It might also be a good idea to ensure that only parent comments can be replied to (as infinite levels of nesting are likely to be a huge headache). One way to handle this is by simply modifying your existing exists validation rule:

// Import these classes...
use Illuminate\Validation\Rule;
use Illuminate\Database\Query\Builder;

// In your validator...
'parent_id' => ['nullable', Rule::exists(ReviewComment::class, 'id')->whereNull('parent_id')],
1 like
ctrlaltdelme's avatar

@jj15 Lol. I actually struggled for hours and got something working, but this all really makes sense! Let me apply your changes and see how it breaks things lol. I was wondering about the routes and whatnot. Makes sense to reduce it like you did

ctrlaltdelme's avatar

@jj15 One question about this though. If the route becomes users/{user}/reviews/{review}/comments, how would you know what piece of content its for if its not in the route? Does that make sense?

I actually did redo my routes and came up with this for reviews and comments.

// Reviews
// Review creation handling is done in ReviewModal
Route::prefix('users/{user}/movies/{movie}')->name('reviews.')->group(function () {
    Route::get('reviews', [UserReviewController::class, 'index'])->name('index');
    Route::get('reviews/{review}', [UserReviewController::class, 'show'])->name('show');
    Route::delete('reviews/{review}', [UserReviewController::class, 'destroy'])
        ->name('destroy')
        ->middleware('auth')
        ->can('destroy', 'review');
});

// Comments
Route::prefix('reviews/{review}/comments')->name('comments.')->middleware('auth')->scopeBindings()->group(function () {
    Route::post('/', [ReviewCommentController::class, 'store'])->name('store');
    Route::delete('/{comment}', [ReviewCommentController::class, 'destroy'])->name('destroy');
});

The store method now goes to http://localhost:8000/reviews/4/comments as a POST. Maybe I need to add user in there? Maybe not? I like going by convention and best practices so if this isn't right, lemme know

jj15's avatar

@ctrlaltdelme

If the route becomes users/{user}/reviews/{review}/comments, how would you know what piece of content its for if its not in the route? Does that make sense?

Your Review model has a MorphTo reviewable() relationship defined, correct? You would use that to retrieve the details of the content being reviewed.

The store method now goes to http://localhost:8000/reviews/4/comments as a POST. Maybe I need to add user in there? Maybe not?

That route is fine. I just wasn't sure if you wanted the review comments to be nested as part of the user's profile. The user isn't necessary since your Review model should also have a BelongsTo user() relationship defined that you can access since you'll be attributing the comment to the currently authenticated user using auth()->id().

1 like
ctrlaltdelme's avatar

@jj15 I did modify it to include the user so it is properly /users/{user}/reviews/{review} for an individual review then all reviews (index) is /users/{user}/movies/{movie}/reviews/{review}

Not sure if that's conventionally correct, and it is quite long (that second one), but it mentally makes sense to me.

I managed to get the comments working properly too! Ill have to share the code for it later. I can't seem to figure out the nesting problem. I'm currently able to nest infinitely with you able to reply to anything but it will keep indenting reply divs. Tbh I modeled Laracasts and I like this system I just dk how to implement the proper nesting level threshold so I'm not loading infinite comments lol.

EDIT: I'm curious on your thoughts on the route. I do think they're quite verbose and wouldn't mind learning how I can shorten them but still have them make sense. Yes, I do have polymorphic relationships for the types of content. Hell, even the Cast and Crew are polymorphic because they can act in a movie or TV show lol

tisuchi's avatar

@ctrlaltdelme A simple question!

Is there any particular reason to write the comment code from Sketch? Why not use some standard package that is already out there? They will make your life easier.

2 likes
ctrlaltdelme's avatar

@tisuchi Other than practice, exposure and the gratification of implementing something that's my own? No, no reason :D

2 likes
jlrdw's avatar

@ctrlaltdelme you could put the nesting in an object via a server fetched partial and have full pagination.

bestmomo's avatar

For info I created a Github repository for a complete blog system to replace Wordpress for my blog, sorry it's french. It features full comment management. May help...

Please or to participate in this conversation.