codenzia/filament-comments

A full-featured commenting system for Filament v4 with threaded replies, channels, polls, events, reactions, mentions, and notifications.

Maintainers

Package info

github.com/Codenzia/filament-comments

Homepage

Issues

pkg:composer/codenzia/filament-comments

Transparency log

Fund package maintenance!

Codenzia

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

v0.3.2 2026-08-11 14:01 UTC

This package is auto-updated.

Last update: 2026-08-11 14:01:58 UTC


README

Latest Version PHP Version Filament Tests License

A full-featured commenting system for Filament v4 and v5 with threaded replies, discussion channels, Slack-style direct messages, polls, events with RSVP, emoji reactions, @mentions, notifications, watchlists, email digests, and a Tribute-powered rich-text composer — all built on Livewire 3.

Why this exists. "Add comments" is one of those innocent-sounding requests that turns into a six-week project: threading, mentions, notifications, moderation, file uploads, link previews, watch/unwatch, DMs. This package ships all of that as a polymorphic drop-in — attach it to any model and you have a Slack-grade discussion surface without leaving Filament.

Try it live: A working integration is included in the Codenzia plugins demo at /admin/demo/comments.

Features

  • Threaded Replies — Nested comment threads with expand/collapse
  • Discussion Channels — Public and private channels with member management
  • Direct Messages — Slack-style 1-on-1 and group private conversations between users
  • Comment Types — Text, polls (vote), and events with RSVP
  • Emoji Reactions — Like, love, laugh, wow, sad, angry (customizable)
  • @Mentions — Mention users, channels, projects, and tasks with configurable triggers
  • Rich Text Editor — Tribute.js-powered textarea with mention autocomplete
  • File Uploads — Images (up to 5MB) and documents (up to 10MB)
  • Comment Moderation — Approval workflow with pending comments modal, plus opt-in per-post holds: an author always sees their own held comment, a moderator sees and decides everyone's
  • Notifications — Email and database notifications for mentions
  • Pin Comments — Pin an important comment to the top of a discussion
  • Resolve Threads — Mark comment threads as resolved (collapse by default)
  • Priorities — Triage a comment as blocker / must / nice, with a fully renameable vocabulary
  • Tags — Free-form per-comment tags with scope-aware autocomplete
  • Bookmarks — Save comments for personal quick reference
  • Link to Tasks — Reference a task from a project discussion comment
  • Watch/Unwatch — Subscribe to all comments on a model, not just @mentions
  • Email Digest — Optional daily summary of unread comments
  • Code Syntax Highlighting — Automatic syntax highlighting for code blocks via highlight.js
  • Checklists — Interactive checklist items within comments ([ ] / [x])
  • Link Previews — Auto-generated Open Graph cards for URLs in comments
  • Quick Comments — Lightweight comment preview + composer for modals and cards (optimistic UI)
  • Dark Mode — Full dark mode support
  • Translations — English and Arabic included

Requirements

Dependency Version
PHP ^8.3
Laravel ^12.0
Filament ^4.0 || ^5.0
Livewire ^3.0

Installation

Install via Composer:

composer require codenzia/filament-comments

Run the install command:

php artisan filament-comments:install

This publishes the config file and migrations. Run migrations:

php artisan migrate

Filament Shield / role-based moderation (optional)

The comment permissions listed in config/filament-comments.php are seeded into the standard Spatie permissions table — so if your app uses bezhansalleh/filament-shield (which transitively requires spatie/laravel-permission), they show up in your Shield UI automatically. No extra wiring.

If you don't use Shield or Spatie, the install command skips the permission seeding step quietly — comments, channels, mentions, and DMs all work without it. Add spatie/laravel-permission to your project later and re-run php artisan filament-comments:install to opt in.

Tailwind v4 Custom Theme

If your Filament panel uses a custom theme (Tailwind CSS v4), add the package's source paths so that utility classes are compiled:

/* resources/css/filament/{panel}/theme.css */
@source '../../../../vendor/codenzia/*/src/**/*.php';
@source '../../../../vendor/codenzia/*/resources/views/**/*.blade.php';

This wildcard pattern covers all Codenzia packages at once.

Then rebuild your assets (npm run build).

Setup

Register the plugin in your panel provider:

use Codenzia\FilamentComments\FilamentCommentsPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        ->plugins([
            FilamentCommentsPlugin::make(),
        ]);
}

Multi-Tenancy

The plugin is compatible with Filament multi-tenancy. It registers cleanly on a panel configured with ->tenant(...) and never crashes at boot — a problem that affects naïvely-written plugins.

Why this matters. Filament boots the panel (and therefore every plugin's boot()) before the tenant is identified by middleware. A plugin that eagerly calls Page::getUrl() or queries records during boot() throws Missing required parameter [tenant] on every request — including the tenant-less login page. This plugin avoids that by:

  • Deferring every navigation URL to a closure, so route generation happens at sidebar-render time (when the tenant is resolved), not at boot.
  • Skipping the dynamic channel/DM sidebar quick-links while no tenant is resolved, so the plugin never runs an unscoped query that would leak channels across tenants in the navigation.

The lazy navigation needs no configuration — it is automatic and behaviour is identical for single-tenant panels (the full channel/DM sidebar is rendered exactly as before).

Built-in tenant scoping (opt-in)

For single-database, column-based tenancy the plugin can confine comments and channels to the current tenant for you. It is off by default (the plugin stays tenant-agnostic); enable it in config:

// config/filament-comments.php
'tenancy' => [
    'enabled' => true,
    'tenant_column' => 'company_id', // the tenant foreign key on the comment tables
    'tenant_resolver' => null,        // null = use the current Filament panel tenant
],

When enabled:

  • A guarded migration adds the nullable tenant_column to the comments and comment_channels tables (child tables — reactions, members, reads, bookmarks, watches — inherit isolation through their already-scoped parent). When tenancy is disabled, the migration is a no-op, so agnostic apps never get the column.
  • A global scope filters every comment/channel query by the active tenant, and the column is stamped on create.
  • When no tenant is resolved (console, seeders, tenant-less pages) scoping is a no-op — those contexts see everything, exactly as with tenancy disabled.

The resolver. Leave tenant_resolver null to use Filament's current panel tenant (Filament::getTenant()?->getKey()) — the zero-config path for Filament tenancy. To integrate another tenancy strategy (or your own "current tenant" service), set it to a callable or — for config:cache safety — an invokable class string that returns the tenant key:

'tenant_resolver' => \App\Tenancy\CurrentTenantResolver::class, // public function __invoke(): ?int

// or a closure (note: not config:cache-safe)
'tenant_resolver' => fn () => app(\App\Tenancy\CurrentTeam::class)->id(),

The default tenant column is a nullable unsignedBigInteger. If your tenant key is a UUID/string, publish your own migration for the column instead and keep tenant_column pointed at it.

Database-per-tenant needs none of this — each tenant already has its own comments/comment_channels tables; leave tenancy.enabled off.

Enabling on an app that already has comments. The scope filters by the tenant key, and rows created before you enabled tenancy have a NULL key — so once the scope is active they match no tenant and become invisible to everyone. If you're turning this on for existing data, backfill the tenant column first (assign each existing comment/channel to its owner tenant) in the same migration or a one-off command, before enabling the scope. Fresh installs have nothing to backfill.

Known limitation under tenancy

While a tenant is pending (the brief window during boot before the tenant resolves), the per-channel and per-DM sidebar quick-links are omitted. Channels and conversations remain fully reachable through the All Channels and All Conversations pages, which are scoped at render time. Single-tenant panels are unaffected.

Usage

Adding Comments to a Model

Add the HasComments trait to any model:

use Codenzia\FilamentComments\Traits\HasComments;

class Post extends Model
{
    use HasComments;
}

Then use the Blade component in your views:

<x-filament-comments::comment :record="$record" />

Or add comments programmatically:

$post->comment('Great article!');
$post->commentAsUser($user, 'Thanks for sharing.');

Discussion Channels

The plugin automatically registers two Filament pages:

  • Manage Channels — Create, edit, and manage discussion channels
  • Discussion Page — View and interact with channel comments

Channels appear in the sidebar navigation automatically.

Direct Messages

The plugin supports Slack-style direct messages — both 1-on-1 and group conversations. DMs appear in their own collapsible sidebar group.

The plugin registers a Direct Messages page where users can:

  • View all their active conversations
  • Start a new conversation with one or more users (group DMs)
  • Click into a conversation to chat
  • Remove themselves from a conversation

Sidebar shortcuts ("+ New Channel" / "+ New Message") auto-open the create modal for quick access. These are permission-aware and hidden when the user lacks the required permission.

Starting a DM Programmatically

use Codenzia\FilamentComments\Models\CommentChannel;

// 1-on-1 DM (legacy two-argument syntax still works)
$dm = CommentChannel::findOrCreateDirectMessage($userId1, $userId2);

// Group DM — pass an array of user IDs
$dm = CommentChannel::findOrCreateDirectMessage([$userId1, $userId2, $userId3]);

This is safe to call multiple times — it returns the existing DM with the exact same set of members if one already exists.

Sidebar MRU Limits

Control how many channels and DMs appear in the sidebar to prevent it from becoming too tall:

// config/filament-comments.php
'sidebar_limit' => [
    'channels' => 5,        // show 5 most recent channels
    'direct_messages' => 5,  // show 5 most recent DMs
],

Set to null to show all items. The "All" link always appears for accessing the full list.

Navigation Groups

Channels and Direct Messages each get their own collapsible sidebar group. Customize the labels:

// config/filament-comments.php
'navigation_groups' => [
    'channels' => 'Channels',
    'direct_messages' => 'Direct Messages',
],

Permissions

The plugin uses Spatie Permission for authorization and works seamlessly with Filament Shield.

Configuration

Each action maps to a Spatie permission name in config/filament-comments.php:

'permissions' => [
    // Channels
    'create_channel' => 'create_comment_channel',
    'update_channel' => 'update_comment_channel',
    'delete_channel' => 'delete_comment_channel',
    'view_channel'   => 'view_comment_channel',

    // Direct Messages
    'view_direct_message'      => null, // null = any authenticated user
    'create_direct_message'    => null,
    'delete_direct_message'    => null,
    'add_member_direct_message' => null,
],

Set any permission to null to allow all authenticated users. Set to a string to require that Spatie permission. Pages return 403 when the user lacks the view_* permission.

'permissions' => [
    'create_channel' => null, // any authenticated user can create channels
    'update_channel' => 'update_comment_channel',
    'delete_channel' => 'delete_comment_channel',
    'view_channel'   => null,
    'view_direct_message'      => null,
    'create_direct_message'    => null,
    'delete_direct_message'    => null,
    'add_member_direct_message' => null,
],

Seeding Permissions

The install command (filament-comments:install) can seed these permissions for you. You can also call it programmatically in your seeders:

use Codenzia\FilamentComments\Commands\InstallCommand;

InstallCommand::seedPermissions();

This is safe to call multiple times — it uses firstOrCreate.

Using with Filament Shield

Shield auto-generates page-level permissions (e.g. View:ManageChannelsPage) but does not discover action-level permissions like create_comment_channel. You need to seed these separately via the install command or your seeder, then assign them to roles in Shield's role editor.

// Example: grant channel management to a "moderator" role
$role = Role::findByName('moderator');
$role->givePermissionTo([
    'create_comment_channel',
    'update_comment_channel',
    'delete_comment_channel',
    'view_comment_channel',
]);

Authorization Logic

  • Channel owners can always edit/delete their own channels
  • Permission holders can manage any channel they have permission for
  • The super_admin role bypasses all checks (standard Shield convention)

Checking Permissions Programmatically

use Codenzia\FilamentComments\Filament\Pages\ManageChannelsPage;

if (ManageChannelsPage::can('create_channel')) {
    // user has the create_comment_channel permission
}

Comment Types

Text Comments

Standard rich text comments with mention support.

Poll Comments

Create polls with a question and multiple options. Users vote directly in the comment thread.

Event Comments

Schedule events with title, date/time, and description. Users can RSVP with Going, Maybe, or Not Going.

Choosing which types the composer offers

The composer's + menu offers every structured type by default. Narrow it where the extras are noise — a design-review panel has no use for "schedule a meeting" in the box where somebody is describing a misaligned button:

// config/filament-comments.php
'composer' => [
    // Poll, Checklist, Risk. Omitting the key entirely keeps all six.
    'types' => ['vote', 'todo', 'risk'],
],
Slug Menu label
vote Poll
event Event
meeting Meeting
todo Checklist
survey Survey
risk Risk

text is not in the list: it is the composer itself, not a choice inside it.

An empty array removes the + control entirely and leaves a plain comment box; attachment buttons are unaffected. Unknown slugs are ignored rather than fatal, and the menu keeps the package's own order however you write the array.

One page can differ from the installation:

<livewire:filament-comments::comments
    :record="$record"
    :composer-types="['vote']"
/>

Pass null (or omit it) for "whatever the config says"; pass [] for "none".

This closes a door, never the data. Comments already posted as an event or a survey keep rendering as one, keep their stored type, and are never filtered out of a thread. Removing a type stops people creating more of it — nothing else. The rule is also enforced server-side: setCommentType() refuses a type the installation does not offer, so a hidden menu entry is not the only thing standing between a caller and an unwanted type.

Mention System

Configure mention triggers for different entity types:

Trigger Entity Config Key
@ Users mentionable
# Channels channel_mentionable
$ Projects project_mentionable
% Tasks task_mentionable

Comment Moderation

Upgrading from 0.3.1? There is nothing to do. Moderation is inert until you register a resolver: both settings below default to null, no config republish is needed, and an installation that configures nothing posts, replies and renders exactly as it did before. The one behaviour that changed is called out at the end of this section.

By default, new comments are auto-approved. To require manual moderation everywhere:

// config/filament-comments.php
'auto_approve' => false,

When false, new comments have is_approved = false and stay out of the thread until approved.

Deciding per post, not per installation

auto_approve is one boolean for the whole install. A review workflow usually needs something narrower — hold this person's comments on this record, let everyone else through. Register a resolver:

// config/filament-comments.php
'moderation' => [
    // fn (?Model $user, ?Model $record): bool — true = publish now.
    'auto_approve_resolver' => fn ($user, $record) => ! $record?->holds($user),
],

Both resolvers accept a closure or — so config:cache keeps working — the name of an invokable class:

namespace App\Comments;

class PublishesImmediately
{
    public function __invoke(?Model $user, ?Model $record): bool
    {
        return $user !== null && ! ReviewAccess::isModerated($user, $record);
    }
}

Leave auto_approve_resolver at null and it is never consulted: approval falls back to auto_approve, exactly as before.

Who sees what is held

A held comment renders to its own author, marked Pending approval — a comment its writer cannot see reads as a post that failed. Everyone else sees nothing. To let a moderator see and act on everyone's held comments, say who a moderator is:

'moderation' => [
    'moderator_resolver' => App\Comments\IsReviewTeam::class,
],

It defaults to null, so nobody is a moderator until you say so. (This is separate from permissions.moderate_comment, which only grants retagging someone else's comment.) A moderator gets inline publish / turn-down controls on each held comment; turning one down deletes it, and CommentItem::rejectComment() is the single method to override if you would rather keep the row.

For your own screens:

Comment::query()->visibleTo(auth()->user(), $record)->get();

approved() and the replies() relation are unchanged — both still mean "published, whoever is looking". visibleTo() is the per-viewer rule, asked for by name.

The one thing that is not a no-op

If your app already sets auto_approve => false, authors now see their own held comments where they previously saw nothing. That is the point — it is the bug this release fixes. The rule is only ever a superset of approved(): it never hides a row that used to render, and with auto_approve at its default true there is nothing unapproved for it to add.

Composer Appearance

Customize the composer background color to match your app's theme:

// config/filament-comments.php
'composer' => [
    'bg' => '#ffffff',          // light mode
    'dark_bg' => '#16181C',     // dark mode
    'show_settings' => false,   // show a settings cog with color picker
    'types' => [...],           // which types the + menu offers — see Comment Types
],

Accepts any valid CSS color value (hex, rgb, hsl, etc.).

When show_settings is true, a cog icon appears in the composer toolbar allowing users to pick from preset background colors. The choice is saved in localStorage.

Reactions

Users can react to comments with emoji. One reaction per user per comment. Customize available reactions:

// config/filament-comments.php
'reactions' => [
    'like' => '👍',
    'love' => '❤️',
    'laugh' => '😄',
    'wow' => '😮',
    'sad' => '😢',
    'angry' => '😠',
],

Quick Comments (Lightweight Preview + Composer)

A lightweight, embeddable comment preview with a quick reply composer — designed for modals, sidebars, and cards where the full CommentsComponent would be too heavy or cause Livewire nesting issues.

<x-filament-comments::quick-comments
    :record="$task"
    :limit="3"
    :view-all-url="url('app/task-details', $task->id)" />
Prop Type Default Description
record Model required Any model using HasComments
limit int 3 Number of recent comments to show
viewAllUrl ?string null URL for the "View all" link

Features:

  • Shows the latest N comments with avatars, names, and timestamps
  • Quick reply textarea with Enter-to-send
  • Alpine optimistic UI — new comments appear instantly without Livewire re-render (modal-safe)
  • Works inside Filament action modals without closing them
  • Accepts any model with HasComments (tasks, projects, invoices, etc.)

Or use the Livewire component directly:

<livewire:filament-comments::quick-comments :record="$task" :limit="3" :view-all-url="$url" />

Pinning Comments

Pin an important comment to the top of a discussion. Only one pinned comment per commentable — pinning a new one automatically unpins the previous.

  • Pin/unpin via the action menu (map pin icon) on any root comment
  • Pinned comment renders at top with an amber highlight border
  • Available to all users who can post in the channel
// Programmatic usage
$comment->pin();
$comment->unpin();

// Query pinned comments
Comment::pinned()->get();

Resolving Threads

Mark root comment threads as resolved to keep discussions clean. Resolved threads collapse by default and show a green "Resolved by {user}" badge.

  • Only root comments (not replies) can be resolved
  • A "Show resolved" toggle in the header lets users show/hide resolved threads
  • Resolved threads are hidden by default
// Programmatic usage
$comment->resolve();        // resolves as current user
$comment->resolve($userId); // resolves as specific user
$comment->unresolve();

// Query scopes
Comment::resolved()->get();
Comment::unresolved()->get();

Priorities

Any comment can carry a priority, so a long thread can be read blocker-first instead of newest-first. The composer shows a chip strip; the chosen priority renders as a colored chip on the posted comment, and appears in the email digest.

Priorities are plain strings, not a PHP enum, so you can rename or re-scale the vocabulary without forking the package. Three ship by default:

Key Label Color
blocker Blocker danger
must Must fix warning
nice Nice to have gray
null (no priority)

The key order in config is the precedence order. The first key sorts first; comments with no priority — and comments carrying a key you later removed — always sort last.

// config/filament-comments.php
'features' => [
    'priorities' => true, // false hides the control and refuses the write
],

'priorities' => [
    'vocabulary' => [
        'urgent' => ['label' => 'Urgent', 'color' => 'danger'],
        'later'  => ['label' => 'Later',  'color' => 'gray'],
    ],
],

label may be a translation key (as the shipped defaults are) or a literal string. color is any Filament color name — chips render through Filament's badge component, so a custom color works without being safelisted in your Tailwind theme.

Querying:

use Codenzia\FilamentComments\Models\Comment;

Comment::byPriority('blocker')->get();              // one priority
Comment::byPriority(['blocker', 'must'])->get();    // several
Comment::orderByPriority()->get();                  // blocker first, null last
Comment::orderByPriority('desc')->get();            // fully reversed: null first

// Compose freely
$post->comments()->withTag('a11y')->byPriority('blocker')->orderByPriority()->get();

Filtering by a priority outside the vocabulary matches nothing rather than degrading to "every comment".

On a comment:

$comment->priority;         // 'blocker' | 'must' | 'nice' | null
$comment->hasPriority();    // bool
$comment->priorityLabel();  // 'Blocker' (translated), or null
$comment->priorityColor();  // 'danger', or 'gray' when unset

The value is normalized on every write path: anything outside the current vocabulary is stored as null, so the column can never hold something the UI cannot render.

Who can set it: anyone posting a comment sets it in the composer. Afterwards, the comment's author can always re-prioritise it; anyone else needs the permission named in permissions.moderate_comment (default null — author only).

Tags

Free-form per-comment labels, with autocomplete drawn from tags already used nearby.

// config/filament-comments.php
'features' => [
    'tags' => true, // false hides the input and refuses the write
],

'tags' => [
    'max_per_comment' => 5,
    'max_length' => 32,
    'suggestion_limit' => 20,
    'suggestion_scope' => null,
],

Tags are normalized on write, whatever the path: whitespace collapsed, blanks dropped, each tag truncated to max_length, duplicates removed case-insensitively (first casing seen wins), and the list truncated to max_per_comment. An empty list stores as NULL, so "no tags" has exactly one representation.

Autocomplete scope. By default suggestions come from every comment on the same commentable_type — that is what makes autocomplete useful on a record that has no tags of its own yet. Narrow or widen it with a closure, or (for config:cache safety) an invokable class string:

'suggestion_scope' => fn ($query, $record) => $query
    ->where('commentable_type', $record->getMorphClass())
    ->where('commentable_id', $record->getKey()),

Querying:

Comment::withTag('a11y')->get();                    // carries this tag
Comment::withAnyTag(['a11y', 'copy'])->get();       // carries at least one
Comment::withAnyTag('a11y')->get();                 // a single string also works

Each comment is returned once even when it matches several tags. Filtering by an empty or blank tag matches nothing.

On a comment:

$comment->tags;       // ['a11y', 'copy'] — always a list, never null
$comment->hasTags();  // bool

Storage tradeoff (worth knowing before you scale). Tags live in a JSON column on the comment row, not in a tags table with a pivot. The only query shapes the plugin needs are "carries tag X" and "carries any of X, Y", both expressible as whereJsonContains, and there is no tag entity to name, describe, merge or own. The cost is that a JSON column cannot be B-tree indexed per value, so tag filtering is a scan. That is the right shape for per-record comment volumes; if you filter tags across millions of comments, promote tags to their own table in your app.

Setting priority and tags in code

Both are fillable, and HasComments::comment() / commentAsUser() take an optional attribute array:

$task->comment('The CTA is unreadable on mobile', null, true, [
    'priority' => 'blocker',
    'tags' => ['a11y', 'mobile'],
]);

// Or directly
$comment->update(['priority' => 'must', 'tags' => ['copy']]);

Priorities and tags in your own UI

Both composers wire the controls for you — embed <livewire:filament-comments::comments> or <livewire:filament-comments::quick-comments> and the triage row appears in the composer, with chips on the rendered comments. Nothing to configure.

To place the controls in a surface of your own, the two Blade partials are public:

{{-- Read-only chips. Renders nothing when the comment has neither. --}}
<x-filament-comments::triage-chips :comment="$comment" />

{{-- The composer row. Point the *-method props at your own Livewire methods. --}}
<x-filament-comments::triage-composer
    :priority="$this->priority"
    :tags="$this->tags"
    :options="$this->priorityOptions()"
    :suggestions="$this->tagSuggestions()"
    set-method="setPriority"
    add-method="addTag"
    remove-method="removeTag"
/>

Add the ManagesTriage trait to your component for priorityOptions(), tagsEnabled(), prioritiesEnabled(), maxTagsPerComment() and canTriageComment().

Turning either feature off hides its control and refuses the write — but never erases values already stored, so flipping the flag back on brings them back intact.

Bookmarks

Personal bookmarks let users save comments for quick reference. Bookmarks are private — not visible to other users.

  • Bookmark icon in the action menu (filled when bookmarked)
  • Toggle on/off per comment
// Check if bookmarked
$comment->isBookmarkedBy();       // current user
$comment->isBookmarkedBy($userId); // specific user

Watching Discussions

Users can watch any commentable model to get notified on ALL new comments, not just @mentions. A bell icon toggles watch state.

// On any model using HasComments
$task->toggleWatch();         // toggle for current user
$task->isWatchedBy();         // check if current user is watching
$task->isWatchedBy($userId);  // check specific user
$task->commentWatchers();     // MorphMany relationship

Link Comments to Tasks

When commenting in a project discussion, users can link a comment to a specific task. The comment displays a small task reference card that links to the task detail page.

Configure the task model in config:

// config/filament-comments.php
'task_mentionable' => [
    'model' => \App\Models\Task::class,
    'column' => ['id' => 'id', 'label' => 'title'],
    'url' => 'admin/tasks/{id}',
],
// Relationship on Comment model
$comment->linkedTask; // BelongsTo relationship

Email Digest

Optional daily email digest of unread comments for watchers. Disabled by default.

// config/filament-comments.php
'digest' => [
    'enabled' => false,
    'schedule' => 'daily',
    'time' => '09:00',
],

Register the command in your scheduler:

// bootstrap/app.php or app/Console/Kernel.php
$schedule->command('filament-comments:send-digest')->dailyAt('09:00');

The digest groups unread comments by source (task, project, channel) and only sends if there are new items in the last 24 hours.

Code Syntax Highlighting

Code blocks in comments (<pre><code>) are automatically syntax-highlighted using highlight.js. Supports PHP, JavaScript, Python, SQL, HTML, CSS, Go, Java, JSON, YAML, XML, C++, Markdown, and more.

// config/filament-comments.php
'code_highlighting' => true, // enabled by default

Highlighting is applied on initial render and after Livewire updates. Uses the github-dark theme by default.

Checklists

Comments support interactive checklists using the [ ] / [x] syntax. Checklist items render as clickable checkboxes that toggle their state via Livewire.

Write in your comment:

- [ ] Review the PR
- [x] Write tests
- [ ] Deploy to staging

Clicking a checkbox updates the comment body in the database. Available to the comment author and other project members.

Link Previews

URLs in comments are automatically enriched with Open Graph metadata cards showing title, description, image thumbnail, and domain.

// config/filament-comments.php
'link_previews' => [
    'enabled' => true,
    'cache_ttl' => 3600, // cache previews for 1 hour
],
  • Previews are fetched server-side on comment save
  • Stored as JSON in the link_previews column
  • Up to 3 link previews per comment
  • Image URLs are excluded (only page URLs are previewed)
  • Cached to avoid repeated fetches

Security

Comment bodies are stored as HTML and rendered unescaped, so the package sanitizes them at render time via Comment::safeHtml() (backed by Codenzia\FilamentComments\Support\CommentSanitizer, built on symfony/html-sanitizer). Scripts, inline event handlers and javascript: URLs are stripped; safe rich-text markup, tribute-mention spans, language-* code classes and checklist tokens are preserved.

If you render comment content in your own Blade views, never echo $comment->comment with {!! !!} — use {!! $comment->safeHtml() !!} (or escape with {{ }} for plain text).

Configuration

Publish the config file:

php artisan vendor:publish --tag="filament-comments-config"

Key configuration options:

return [
    // Models
    'comment_class' => \Codenzia\FilamentComments\Models\Comment::class,
    'user_model' => null, // defaults to auth config
    'project_model' => \App\Models\Project::class,
    'event_model' => null, // optional: persist events to a model

    // Table names
    'table_name' => 'comments',
    'reactions_table_name' => 'comments_reactions',
    'channels_table_name' => 'comment_channels',
    'channel_members_table_name' => 'comment_channel_members',

    // Navigation groups
    'navigation_groups' => [
        'channels' => 'Channels',
        'direct_messages' => 'Direct Messages',
    ],

    // Behavior
    'auto_approve' => true, // set false to require moderation
    'delete_replies_along_comments' => false,
    'enable_add_to_calendar' => true,

    // Optional affordances — each hides its control AND refuses the write,
    // without erasing values already stored.
    'features' => [
        'bookmarks' => true,
        'priorities' => true,
        'tags' => true,
    ],

    // Priority vocabulary. Key order = precedence order (first sorts first,
    // null always last). See "Priorities" above.
    'priorities' => [
        'vocabulary' => [
            'blocker' => ['label' => 'filament-comments::messages.priorities.blocker', 'color' => 'danger'],
            'must'    => ['label' => 'filament-comments::messages.priorities.must',    'color' => 'warning'],
            'nice'    => ['label' => 'filament-comments::messages.priorities.nice',    'color' => 'gray'],
        ],
    ],

    // Tag caps + autocomplete scope. See "Tags" above.
    'tags' => [
        'max_per_comment' => 5,
        'max_length' => 32,
        'suggestion_limit' => 20,
        'suggestion_scope' => null, // null = same commentable_type
    ],

    // Editor
    'editor' => [
        'placeholder' => 'Type your comment here...',
        'height' => 100,
    ],

    // Composer appearance
    'composer' => [
        'bg' => '#ffffff',          // light mode background
        'dark_bg' => '#16181C',     // dark mode background
        'show_settings' => false,   // settings cog with color picker
    ],

    // Mentions
    'mentionable' => [
        'model' => \App\Models\User::class,
        'trigger' => '@',
        'column' => [
            'id' => 'id',
            'label' => 'name',
            'value' => 'name',
            'email' => 'email',
            'avatar' => 'profile_photo_path', // see "User avatars" below
        ],
        'avatar_disk' => 'public',
        'url' => 'admin/users/{id}',
    ],
];

User avatars

User pickers (@mentions, DM recipients, channel members) resolve each person's avatar in priority order — this is how an app supplies its own avatars:

  1. Filament's HasAvatar contract. If your User model implements Filament\Models\Contracts\HasAvatar, its getFilamentAvatarUrl() is used — the same avatar Filament's own UI shows. This is the recommended way.
  2. The configured column/accessor (mentionable.column.avatar, default profile_photo_path). It may hold a full URL, or a path resolved against mentionable.avatar_disk (default public).
  3. A generated initials avatar (ui-avatars.com), so a bare users table (just id / name / email) still renders something sensible.

The package never assumes the avatar column exists: if it isn't a real column on your users table it is skipped (queries never error) and resolution falls through to the next step. This is centralized in Codenzia\FilamentComments\Support\MentionablesavatarUrl() resolves the URL and selectColumns() builds a safe SELECT that only references real columns.

Database Schema

The package creates seven tables:

Table Purpose
comments Main comments with polymorphic relation, threading, type, approval, pin, resolve, link previews, priority, tags
comment_channels Discussion channels and DMs with type, visibility, icon, project association
comment_channel_members Channel membership pivot table
comment_channel_reads Read tracking per channel per user
comments_reactions Emoji reactions per user per comment
comment_bookmarks Personal bookmarks per user per comment
comment_watches Polymorphic watch subscriptions per user per model

Models

Comment

  • channel() — Belongs to a channel
  • commentator() — Comment author
  • parent() — Parent comment (for threading)
  • replies() — Child comments
  • reactions() — Emoji reactions
  • bookmarks() — User bookmarks
  • resolvedBy() — User who resolved the thread
  • linkedTask() — Linked task (configurable model)
  • pin() / unpin() — Pin/unpin this comment
  • resolve() / unresolve() — Resolve/unresolve this thread
  • isBookmarkedBy() — Check if bookmarked by a user
  • hasPriority() / priorityLabel() / priorityColor() — Priority accessors
  • hasTags() — Whether the comment carries any tag
  • scopePinned() / scopeResolved() / scopeUnresolved() — Query scopes
  • scopeByPriority() / scopeOrderByPriority() — Priority filter + precedence sort
  • scopeWithTag() / scopeWithAnyTag() — Tag filters

CommentBookmark

  • comment() — The bookmarked comment
  • user() — The user who bookmarked

CommentWatch

  • watchable() — Polymorphic relation to the watched model
  • user() — The watching user

CommentChannel

  • comments() — All channel comments
  • members() — Channel members (from project if linked, otherwise pivot table)
  • channelMembers() — Direct channel members (always from pivot table)
  • createdBy() — User who created the channel
  • project() — Associated project
  • scopeChannels() — Filter to only channels
  • scopeDirectMessages() — Filter to only DMs
  • isDirectMessage() / isChannel() — Type checks
  • findOrCreateDirectMessage(int|array $userIds) — Find or create a DM between users (supports 1-on-1 and group DMs)
  • dmDisplayName() — Display name showing other participants (e.g. "Alice, Bob +2")
  • dmAvatarUrl() — Avatar URL of the other participant (1-on-1 DMs)

Events

Event Dispatched When
CommentAdded New comment created
CommentDeleted Comment removed
UserMentioned User mentioned in a comment
EventAddedToCalendar Event comment added to calendar

Traits

Trait Purpose
HasComments Add to any model to enable commenting + watching
ExtractsMentions Parse HTML for tribute mentions
HasMentionable Build mentionable lists from config

HasComments now includes watch/unwatch support:

  • commentWatchers() — MorphMany of watchers
  • isWatchedBy($userId) — Check if a user is watching
  • toggleWatch($userId) — Toggle watch on/off, returns boolean

License

This package is dual-licensed:

  • MIT License — Free for open source projects under an OSI-approved license.
  • Commercial License — Required for proprietary/commercial projects. Visit codenzia.com for details.

See LICENSE.md for full terms.