byrcsc / laravel-comments
Threaded comments for Eloquent models, with guest authors, moderation statuses, reactions, edit history, attachments, pinning, and lifecycle events.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^12.0||^13.0
- illuminate/database: ^12.0||^13.0
- illuminate/events: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.3.1
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
- intervention/image: Required by Comment::attachImage(), which drives the framework's Image facade (^4.0). Nothing else in the package needs it.
README
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.
| Laravel | Tested PHP versions |
|---|---|
| 12.x | 8.3, 8.4 |
| 13.x | 8.3, 8.4 |
Installation
Install the package and publish its migrations:
composer require byrcsc/laravel-comments
php artisan vendor:publish --tag="comments-migrations"
php artisan migrate
Publish the configuration before the migration when you need custom table names or non-integer actor identities:
php artisan vendor:publish --tag="comments-config"
Set COMMENTS_ACTOR_KEY_TYPE to uuid, ulid, or string as needed. It
covers commentator and reactor identities. The commentable model key is
independent; adjust commentable_id in the published migration when those
models do not use integer keys.
Notification wording and its mail views publish separately, and only when you want to change them:
php artisan vendor:publish --tag="comments-translations" php artisan vendor:publish --tag="comments-views"
What the package stores
A comment belongs to one already-persisted commentable record and is written by a commentator: any Eloquent model, or a guest identified only by a name and an email address. Comments form threads through replies, carry a moderation status, and accumulate reactions, revisions, and attachments.
The package controls comment state and history. It does not control what your application shows:
- Status is package state, not visibility. A comment is
pending,approved,rejected, orspam. The package records transitions and fires events; deciding what a visitor sees stays in your queries, and theapproved()scope is the tool for it. - The body is stored verbatim. No sanitization, no markdown, no rendering. Escape or render on output in your application. Treat every body, and every guest name, as untrusted input.
Quick start
Add HasComments to the model that receives comments:
use ByRcsc\LaravelComments\Concerns\HasComments; class Post extends Model { use HasComments; }
Write a comment, reply to it, react to it:
$comment = $post->comment('Great write-up!', by: $user); $reply = $comment->reply('Agreed, especially the last section.', by: $teammate); $comment->react('👍', by: $teammate);
Guest comments carry a name and an email instead of a model, and start out
pending by default:
$guest = $post->commentAsGuest( 'Where can I download the slides?', name: 'Jane', email: 'jane@example.com', ); $guest->approve(by: $moderator);
Read a thread the way you would read any relation:
$post->comments()->approved()->topLevel()->with('replies')->get();
That is the whole loop. Threading depth, moderation hooks, reactions, revisions, attachments, pinning, counts, notifications, and the rest are in the documentation.
What is included
- Polymorphic commentators, so users, admins, bots, or any other model can comment, plus guest comments identified by name and email.
- Threaded replies through a self-referencing parent, with a configurable maximum depth and scopes for top-level comments and whole threads.
- Moderation statuses (
pending,approved,rejected,spam) withapprove(),reject(), andmarkAsSpam(), a scope per status, and aDecidesCommentStatushook a commentable implements to choose the initial status. - Reactions with a configurable allowlist,
react(),unreact(), andtoggleReaction(), one row per reactor per reaction enforced by the database, and areactionSummary()that eager loads a whole thread's totals in one query. - Soft deletes with tombstones for threads, subtree removal on force delete, an
edited_attimestamp, and arevisions()relation recording the prior body and the editor on every body change. - Attachments as metadata rows for files the application stored itself, plus
attachImage()sugar built on Laravel'sImagefacade whenintervention/imageis installed. - Optional denormalized comment counts on the commentable's own table,
maintained in atomic increments and repairable with
recountComments()or thecomments:recountcommand. - Pinning through
pin()andunpin(), withpinned()andpinnedFirst()scopes and their own events. - One opt-in notification, disabled by default: the author of a comment is notified when it receives a reply. Wording and mail views are publishable and locale-aware.
- Lifecycle events for every transition: created, updated, deleted, restored, approved, rejected, marked as spam, reactions, attachments, and pinning.
- A
CommentPolicyyou may register, factories for every model with states for guest, each status, pinned, soft-deleted, and threaded comments, and aComments::fake()helper that records what your application asked the engine for instead of writing it.
Important behavior
- Guest comments start
pendingregardless of the configured default status, unless your model's own hook says otherwise. Approving guest content is a decision the package will not make for you. - Transitions are idempotent. Approving an approved comment writes nothing and fires nothing, so counts and notifications built on the events cannot double up. Each comment carries its own status; approving one never touches its replies.
- Reactions require an identified reactor. Guests cannot react, because deduplication is impossible without an identity, and the database enforces the same rule behind the engine.
- Only commentators that are Laravel
Notifiablemodels receive the reply notification. Guests are never emailed: the package refuses to send mail to an unverified address it cannot offer an unsubscribe path for. Notification delivery is opt-in and disabled by default. - The reply notification fires when a reply enters the approved set, at most
once per reply for its whole life, proven by a
reply_notified_atcolumn rather than anything held in memory. - Soft deleting a comment keeps its replies and works as the thread's tombstone; force deleting removes the whole subtree through the foreign key. A tombstone neither takes new reactions, attachments, or edits, nor gives up the ones it had.
- Maximum thread depth is enforced when the reply is created. Existing threads are never reshaped by a config change.
- Revisions,
edited_at, and counts are maintained through Eloquent model events, soedit(),update(), and a plain attribute save all leave the same trace, whilesaveQuietly(), the query builder, and raw SQL leave none.comments:recountis the backstop for counts, and--dry-runshows what it would change first. - Revision rows are append-only by convention rather than by proof: there is no hash chain and no tamper evidence here. Treat the history as a record for a moderator to read, not as evidence that would survive a hostile database.
- The package never re-moderates an edited comment.
CommentUpdatedpluswasChanged('body')is the hook for sending one back topending, because only your application can tell a fixed typo from an approved comment edited into an advert. The event fires after the revision is filed, so the listener has the previous body to judge against. - Denormalized counts are off until a model returns a column name from
commentsCountColumn(); the column and its migration are the application's. They include approved, non-deleted comments only, and every status change counts however it was made. - Pinning is independent of moderation, and several comments may be pinned on the same record: a one-pin rule is a decision for your controller, not the engine.
attach()records metadata about a file your application already stored. The package never opens the file, never checks that it is there, and never deletes it.AttachmentRemovedfires for every row a force delete takes, while the disk and path are still readable, and that is the file-cleanup hook.attachImage()is the only path where the package writes bytes to a disk. It needs the framework'sImagefacade, which arrived in Laravel 13, andintervention/image, a Composer suggestion rather than a requirement; without it the call throwsImageSupportMissingException.- The engine never authorizes its own methods.
CommentPolicyships with the package but is not registered for you: the provider defines no gates and no policies. Register it withGate::policy(Comment::class, CommentPolicy::class)and enforce it where your application calls the engine. Moderation abilities deny until you override them. Comments::fake()fakes writes, not reads. A faked comment carries a key and can be replied to, but it is not a row: relations and scopes still read a database that has nothing in it. Ask the fake what it recorded instead.- The package provides no UI, role package, or tenancy layer. Your application owns those.
Out of scope
The package draws its edges deliberately. What follows describes what it sets out to do rather than what it might do later: treat none of it as planned work, and none of it as ruled out forever.
- A comment UI. No Blade components, no Livewire, no endpoints. The package ships the queries a comment section is built from; the interface is yours.
- Rendering and sanitization. Bodies are stored verbatim and never parsed. Markdown, HTML filtering, and output escaping belong to the application that renders them.
- Mentions. Parsing
@namesout of a body is rendering-adjacent and app-specific. The created and updated events carry everything a mention scanner needs. - Subscriptions and broader notifications. The reply notification is the one case with an unambiguous recipient. Watchers, digests, and notify-the-author-of-the-post flows need a subscription model only your application can define; build them on the events.
- Emailing guests. A guest email is unverified input, not a mailbox the package will write to.
- Rate limiting and spam detection. Guests defaulting to
pendingand thespamstatus are the hooks. Throttling and detection live in your middleware or your spam service integration. - Generic reactions. Reactions attach to comments, not to arbitrary models.
- Multi-stage moderation. One status field, one transition at a time. There are no approval stages, no sign-off order, and no verifiable action history. The moderation events are the hook if you need to drive one.
- Roles, teams, org charts, or tenancy. The policy and your resolvers decide who moderates. The package never expands a role into its members.
- Blocking every write path. Counts, revisions, and
edited_atreact to Eloquent model events. Raw SQL and query-builder writes are not intercepted.
Documentation
- Introduction
- Installation and setup
- Configuration
- Quick start
- Commentable models
- Threads and replies
- Initial status
- Moderation
- Reactions
- Revisions
- Deletion
- Attachments
- Comment counts
- Pinning
- Notifications
- Authorization
- Events
- Console commands
- Rendering and safety
- Testing
- Troubleshooting
Development
The local checks mirror CI:
composer install
composer test
composer analyse
vendor/bin/pint --test
PHPStan runs at max with no baseline. Tests use SQLite locally and run
against MySQL and PostgreSQL in CI. tests/Release/ pins the public surface,
the documented guarantees, and the quick start above, run against the
workbench's own models. A failure there is asking whether you meant to change
the API. See CONTRIBUTING.md.
workbench/ is a bootable demo application that installs the package the way
a real application would, and exercises every integration seam: guest
comments, moderation hooks, threading depth, reactions, revisions,
attachments and the image pipeline, counts, pinning, the reply notification,
and a policy above this package's. composer build sets it up; see
workbench/README.md for the demo loop.
Versioning
The package follows semantic versioning.
- Upgrading within
1.xis safe. Nothing you use will break. - Only a new major version, like
2.0.0, can break your code. - If the README or the documentation describes it, it is safe to build on. If they don't, treat it as internal and expect it to change.
Bug fixes go into the newest version only. To get a fix, upgrade to it.
Questions and issues
- Stuck, or have an idea? Start a discussion. Usage questions and feature ideas both live there.
- Found a bug you can reproduce? Open an issue. A failing test is the fastest way to a fix, and a short reproduction is the next best thing.
- Found a security problem? Please don't open a public issue. See SECURITY.md for how to report it privately.
- Planning a pull request? CONTRIBUTING.md covers the setup and the three checks it needs to pass.
This package is maintained by one person, so replies can take a while. Everything gets read.
Credits
License
MIT. See LICENSE.md. Changelog in CHANGELOG.md.