kirschbaum-development/nova-comments
A Laravel Nova package that adds comment threads to your resources, letting teams discuss records without leaving the admin panel. Configure which models accept comments and keep collaboration close to the data.
The package is a Nova field plus a polymorphic Comment model — there is no application to learn, only four edits to make. Budget half a day for the first integration, including a negative authorization test.
The four edits, in order:
composer require kirschbaum-development/nova-comments
php artisan vendor:publish --provider="Kirschbaum\NovaComments\NovaCommentsServiceProvider"
php artisan migrate
Confirm the provider namespace by listing vendor/kirschbaum-development/nova-comments/src before you copy the command — the class name is the one thing worth verifying rather than trusting. Publish by tag, not bare --force, so a later re-publish doesn't overwrite the migration you've already customized.
Then:
HasComments to the target model — the concrete model, not an abstract base, unless every subclass genuinely wants a thread.use Kirschbaum\NovaComments\Fields\Comments;
public function fields(NovaRequest $request): array
{
return [
// ...
Comments::make()->onlyOnDetail(),
];
}
canSee / canRun and the policy behind them on day one, not after launch.Where to look first, in this order. Skip the README; read the code you just published.
(commentable_type, commentable_id)? Does the author FK use nullOnDelete or (badly) cascadeOnDelete?Comment model — $fillable, casts, and how the author relation is resolved.vendor/ — which Nova abilities it checks on create and destroy. The docs won't tell you; this file will.First use case to validate: an internal handoff thread on an Order or Ticket detail page — "customer called back, refund promised", "escalated to finance". It's low-stakes, internal-only, and it proves the two things that actually matter: that the panel renders on your Nova version, and that the authorization you wrote holds for a non-privileged user. Do not launch with a customer-facing or compliance-flavored use case; those are a different class of product.
Smoke test: create, edit, delete a comment as an admin, then reload the same page as a user who shouldn't see the panel. The second check is the one that matters.
Make adding comments to the Nth resource mechanical. Once the first integration works, the day-to-day work is repetition. Write yourself a checklist: trait on the model, field in fields(), policy methods, morph map entry (see below), factory state for tests. Ten minutes per resource, and no decisions to re-litigate.
Decide the morph map on day one. This is the single decision that's expensive to reverse. If you ever call Relation::enforceMorphMap() after rows exist, old rows store FQCNs and new rows store aliases and you're writing a data migration. Choose explicitly now:
// AppServiceProvider::boot()
Relation::enforceMorphMap([
'order' => \App\Models\Order::class,
'ticket' => \App\Models\Ticket::class,
]);
Or accept FQCNs forever. Don't drift into the middle.
Gate on the record, not the role. The valuable pattern is authorization that resolves against this specific record, which is what lets you say "sales can comment on their own accounts, support on any ticket, nobody on invoices":
Comments::make()
->onlyOnDetail()
->canSee(fn (NovaRequest $request) => $request->user()->can('viewComments', $this->resource))
->canRun(fn (NovaRequest $request) => $request->user()->can('createComment', $this->resource));
Put the ability methods in a policy on the target model (or a dedicated CommentPolicy) rather than scattering role checks across resources. When the inevitable "who can read vs. write?" question arrives, the answer is one file, not a grep.
Notifications go in an observer, not the field. The most common follow-up request is "notify the assignee when someone comments." Register a Comment::created observer that dispatches a queued notification to the relevant people except the author. Keeping it out of the Nova layer means the same behavior fires from console commands, imports, and seeders.
Comment::created(function (Comment $comment) {
$comment->commentable
->notifiableUsers()
->reject(fn ($u) => $u->is($comment->author))
->each(fn ($u) => $u->notify(new CommentPosted($comment)));
});
Own the model, own the schema. The strongest argument for this package over a hand-rolled field is that persistence is plain Eloquent. Publish the migration and treat comments as your table: add deleted_at, edited_at, a visibility enum, an author_name snapshot, or team_id. Extend the package Comment, point the config at your subclass, and you keep the field UI while growing a data model that survives leaving Nova entirely.
// config/nova-comments.php
'comment_model' => \App\Models\Comment::class,
Query comments outside Nova. The same relation powers digests, exports, and reporting — never build a second notes table for those.
$order->comments()->with('author')->latest()->get();
// Weekly handoff digest
Comment::where('commentable_type', (new Order)->getMorphClass())
->where('created_at', '>=', now()->subWeek())
->get()
->groupBy('commentable_id');
Note getMorphClass() rather than Order::class — it respects the morph map whether or not you adopted one. Write one export command on day one; it kills the lock-in objection before anyone raises it.
Unread badges are a schema decision, not a package feature. Add a read_at per-user join table (comments are per-record, readers are per-team — a single column won't model it), and a whereDoesntHave('reads', ...) scope the field can use or the resource can surface as a tab badge.
Test the denial path, not the happy path. Use RefreshDatabase, factory a model with the trait, actingAs a Nova user:
public function test_user_without_ability_cannot_see_comments(): void
{
$this->actingAs($this->userWithoutPermission())
->getJson("/nova-api/orders/{$this->order->id}")
->assertJsonMissing(['component' => 'comments']);
}
The field rendering successfully proves very little. The negative case is your actual coverage.
composer update changed Nova-version code paths without a semver signal. Read the composer.json constraints to establish whether Nova 4 is retained or the floor was raised, then pin deliberately. If you're on Nova 4 for several more quarters, upgrading is optional and non-zero risk — deferring is defensible.npm run build / nova:publish compiles under your Node version in staging before shipping to production.(commentable_type, commentable_id). A missing composite index on morph lookups is a slow query that only shows up in production. Add it yourself if it isn't there.deleting event, or accept orphans knowingly — and decide the soft-delete case deliberately rather than inheriting an accident.cascadeOnDelete silently erases history. Use nullOnDelete and keep an author_name snapshot so a thread still reads "Jane Doe (deactivated)" three years later.vendor:publish --force overwrites your migration and config. Once either is customized, publish by tag and diff first. Commit the published migration rather than leaning on loadMigrationsFrom.config:cache after config edits. Publishing config in a cached environment and wondering why comment_model changes don't apply is a reliable twenty-minute detour.canSee/canRun on the field. Those four cover most real requests. Anything beyond — threading, attachments, reactions, email-in reply — is a fork you own, so push back on the ticket rather than patching vendor code.How can I help you explore Laravel packages today?