byrcsc/laravel-comments · 1.x
Introduction.
Threaded comments for Eloquent models, with guest authors, moderation statuses, reactions, edit history, attachments, pinning, and lifecycle events.
Threaded comments for any Eloquent model.
Your posts need comments? A sale needs a remark? A ticket needs an internal note? Add one trait to the model and it has them — threaded, moderated, and ready for reactions, edit history, and attachments when you need those too.
The table is polymorphic, so posts, orders, tickets, and invoices all share it.
The package handles storing and moving comments. Your application keeps its UI, its rendering, its users, and its moderation rules.
| Requirement | Supported versions |
|---|---|
| PHP | 8.3, 8.4 |
| Laravel | 12.x, 13.x |
The package follows semantic versioning: upgrading within 1.x is safe. Source
and issues live at
github.com/byrcsc/laravel-comments.
What the package stores
A comment belongs to one already-persisted commentable record and is written by a commentator — any Eloquent model — or by a guest identified only by a name and an email address. Exactly one of the two, never both.
Comments form threads through parent_id. Each one carries a moderation
status, and accumulates reactions, revisions, and attachments.
What the package does not do
Two boundaries shape everything else, and both are worth knowing before you write a line:
Status is package state, not visibility. A comment is pending,
approved, rejected, or spam. The package records transitions and fires
events; what a visitor sees is decided by your queries, and the approved()
scope is the tool for making that decision.
The body is stored verbatim. No sanitization, no markdown, no rendering. Escape or render on output. Treat every body, every guest name, and every guest email as untrusted input — see rendering and safety.
The package also owns no routes, no controllers, and no views beyond one mail template. It never authorizes its own methods, and it never resolves an authenticated user on your behalf.
What is included
- A
HasCommentstrait that makes any model commentable in one line, withcomment()andcommentAsGuest()write methods. - Threads through
reply()andreplyAsGuest(), with a configurable depth limit and adepth()walk. - Moderation:
approve(),reject(), andmarkAsSpam(), each idempotent and each firing exactly one event per real state change, plus scopes for every status. - Initial status resolution from config, with a
DecidesCommentStatushook on the commentable that beats it. Guests startpendingwhatever the default says. - Reactions with database-enforced deduplication, a configurable allowlist, and a grouped-count relation that renders a whole thread in one query.
- Automatic edit history: every body change stamps
edited_atand files an append-only revision holding what the comment said before. - Attachment rows for files your application already stored, plus an
attachImage()convenience that runs the framework's image pipeline. - Pinning, independent of moderation, with a
pinnedFirst()ordering scope that behaves the same on MySQL, PostgreSQL, and SQLite. - Soft deletes with readable tombstones, and force deletes that take the whole reply subtree through the database's cascade.
- An opt-in denormalized comments count on the commentable's own table,
maintained in atomic increments, with
comments:recountas the repair tool. - One shipped notification — somebody replied to your comment — off by default, at most once per reply, never sent to a guest.
- Eighteen lifecycle events under one base class, a publishable
CommentPolicy, factories for all four models, and aComments::fake()recorder for your own test suite.
A first comment
Add the trait to the model that receives comments, and write one:
use ByRcsc\LaravelComments\Concerns\HasComments;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasComments;
}
$comment = $post->comment('Great write-up!', by: $user);
$comment->reply('Agreed, especially the last section.', by: $teammate);Adding the trait is the whole integration. There is no registration step, no service to bind, and no configuration you have to touch first.
Design boundaries
Four decisions explain most of the API.
One identity per comment. A comment names a commentator model or a guest, never both and never neither. A guest is a name and an email, stored as given and verified by nothing — which is why guests cannot react and are never notified.
Idempotent transitions. Approving an approved comment writes nothing and fires nothing. Pinning a pinned comment does the same. One event means one real state change, which is what counts and notifications downstream are built on.
Model events are the seam. Edit history, the depth limit, the length limit,
and count maintenance all ride Eloquent's model events. Anything that goes
around them — saveQuietly(), the query builder, raw SQL — goes around these
too.
The application owns the files. An attachment is a row of metadata about a file your application stored. The package never opens it, never checks it is there, and never deletes it.
Where to go next
Continue with installation and setup, then follow the quick start to build a comment section end to end. The remaining sections document threads, moderation, reactions, revisions, attachments, counts, events, authorization, and every Artisan command the package ships.