NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Comments for Laravel

Threaded replies

Replies are stored with both their root subject — so flat listings on the post still work — and a parent_id link to the comment they answer. Write one with reply() on the builder, or parent: on the DTO:

use RoundlyConsulting\Comments\Facades\Comments;

$root = Comments::on($post)->as($alice)->body('Great article.')->post();

$reply = Comments::on($post)->as($bob)->reply($root)->body('Agreed.')->post();

$reply->parent_id === $root->id;   // true
$reply->commentable->is($post);    // true — a reply lives on its parent's subject

Comments::on($otherPost)->reply($root)->body('Nope')->post();   // throws InvalidCommentParentException

The subject you pass to Comments::on() is a scope: a parent that belongs to another subject — another id, or another morph type with the same id — throws InvalidCommentParentException rather than moving the reply to the parent’s subject. The same rule applies to a WriteCommentData passed to write().

Reading threads

// Direct children of a comment (every status — filter before showing them publicly):
$comment->replies;

// The comment a reply answers:
$reply->parent;

// The public thread: visible + approved top-level comments, with only visible + approved
// replies eager-loaded (bounded by max_depth). A hidden, pending or visible(false) comment
// never loads, and neither does anything below it:
$post->threadedComments()->get();

// Every root with every reply, for a moderation view:
Comments::for($post)->rootsOnly()->withReplies()->get();

// Only top-level comments:
$post->comments()->whereNull('parent_id')->get();
$post->comments()->roots()->get();

threadedComments() is the public thread. The query’s withReplies() follows the query’s visibility: on a visible() query (in either order) only visible + approved replies load, at every level; without visible() every reply loads, for a moderation view.

Depth limit

A top-level comment is depth 1, its direct reply depth 2, and so on. Replying below comments.max_depth (default 5) throws MaxReplyDepthExceededException — a soft-deleted ancestor still counts towards the depth. threadedComments() and the query’s withReplies() eager-load nested replies level by level down to max_depth, so even a deep thread loads in a fixed number of queries.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.