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

The package works with zero configuration — every key has a sensible default. The published config/comments.php, with its docblocks trimmed:

use RoundlyConsulting\Comments\Models\Comment;

return [
    'model' => Comment::class,
    'key_type' => env('COMMENTS_KEY_TYPE', 'bigint'),
    'require_approval' => env('COMMENTS_REQUIRE_APPROVAL', false),
    'max_length' => env('COMMENTS_MAX_LENGTH', 5000),
    'max_depth' => env('COMMENTS_MAX_DEPTH', 5),
    'order' => env('COMMENTS_ORDER', 'latest'),
    'blocklist' => [],
    'blocklist_action' => env('COMMENTS_BLOCKLIST_ACTION', 'reject'),
    'mention_resolver' => null,
    'authorization' => env('COMMENTS_AUTHORIZATION', false),

    // Auto-hide a comment on an upheld report / threshold crossing (reports-for-laravel).
    'moderation' => [
        'on_resolved' => 'hide', // 'hide' | null — anything else throws
        'auto_hide' => true,     // react to the global reports threshold
    ],

    // Attachments + inline [media:UUID] rendering (media-library-for-laravel).
    'media' => [
        'attachments_bucket' => 'attachments',
        'visibility' => env('COMMENTS_MEDIA_VISIBILITY', 'private'),
        'disk' => env('COMMENTS_MEDIA_DISK'),
        'private_disk' => env('COMMENTS_MEDIA_PRIVATE_DISK', 'local'),
        'accepted_mime_types' => [],
        'max_file_size' => null,
        'responsive_widths' => null,
        'temporary_url_lifetime' => null,
        'inline' => [
            'enabled' => true,
            'default_variant' => '',
            'on_missing' => 'strip', // 'strip' | 'keep' (anything else throws)
        ],
    ],
];

Every key

KeyDefaultPurpose
modelComment::classEloquent model that stores comments. Point it at your subclass of the packaged Comment; any other class throws.
key_typebigintKey type of the polymorphic columns: bigint, uuid or ulid — anything else throws. Read when the migration runs; keys are stored and read back as-is.
require_approvalfalseNew comments start pending and must be approved before they count as visible.
max_length5000Max body characters, at least 1; longer throws InvalidCommentBodyException.
max_depth5Max reply nesting, at least 1 (a top-level comment is depth 1; soft-deleted ancestors still count); also bounds threaded eager-loading.
orderlatestOrder of approvedComments: latest (newest first) or oldest; anything else throws.
blocklist[]Banned words (whole-word, case-insensitive) or delimited regexes such as /badword/i. A blank or non-string entry throws.
blocklist_actionrejectOn a match, on write and edit: reject (throw CommentRejectedException), pending or hidden; anything else throws.
mention_resolvernullInvokable class-string or [Class::class, 'method'] pair, built through the container, resolving an @handle to a model — never a closure (config:cache can’t store one). null only stores handles; a value that doesn’t resolve to a callable — an unknown class, a missing method — throws.
authorizationfalseConsult the Comment policy before every mutation — create, update, delete, restore, moderate, lock and unlock.
moderation.on_resolvedhideHide a comment when a report against it is upheld; null (or a blank value) disables; anything else throws.
moderation.auto_hidetrueHide a comment when it crosses the global reports.threshold.
media.attachments_bucketattachmentsBucket name the comment registers on Media Library.
media.visibilityprivateprivate (signed URLs) or public; anything else throws — a typo never picks a visibility for you.
media.disknullDisk for every attachment; null or blank = by visibility (private → media.private_disk, public → the Media Library default disk).
media.private_disklocalDisk for private attachments and their variants when media.disk is null — keep it non-public (COMMENTS_MEDIA_PRIVATE_DISK).
media.accepted_mime_types[]Mime allowlist; [] accepts any type.
media.max_file_sizenullMax attachment size in bytes, at least 1, enforced on upload (FileUnacceptableForBucket); null = Media Library’s media.max_file_size.
media.responsive_widthsnullWidth ladder for image attachments — a list of positive integers (variants named responsive-<width>); null = Media Library’s media.responsive.widths.
media.temporary_url_lifetimenullSigned attachment URL lifetime in minutes, at least 1; null = the Media Library default.
media.inline.enabledtrueExpand [media:UUID] tokens in renderBody().
media.inline.default_variant'' (empty)Variant used when a token names none — a string; empty = a responsive <img>.
media.inline.on_missingstripstrip or keep tokens that don’t resolve; anything else throws.

Environment

The common switches are env-driven, so you rarely need to publish the file:

COMMENTS_KEY_TYPE=bigint
COMMENTS_REQUIRE_APPROVAL=true
COMMENTS_MAX_LENGTH=2000
COMMENTS_MAX_DEPTH=3
COMMENTS_ORDER=oldest
COMMENTS_BLOCKLIST_ACTION=pending
COMMENTS_AUTHORIZATION=true
COMMENTS_MEDIA_VISIBILITY=private
COMMENTS_MEDIA_PRIVATE_DISK=s3-private

Every bool switch is parsed as a boolean, so .env values mean what they say: true, 1, on and yes turn it on; false, 0, off and no turn it off. COMMENTS_REQUIRE_APPROVAL=1 holds new comments, COMMENTS_AUTHORIZATION=off skips the policy. A blank value (COMMENTS_AUTHORIZATION=) is not set, so the default applies. Anything else throws an InvalidConfigurationException instead of quietly reading as the default.

Strict settings

Every other setting is just as strict, and every failure throws RoundlyConsulting\PackageToolkit\Exceptions\InvalidConfigurationException naming the key. A setting that is not set — absent, null, or blank like a host’s COMMENTS_MAX_LENGTH= — takes its default; an optional one (media.disk, media.max_file_size, media.responsive_widths, media.temporary_url_lifetime) stays unset:

  • Integers accept an int or a plain integer string — every env value is a string — so COMMENTS_MAX_LENGTH=five or 5.5 throws rather than becoming 0. Lengths, depths, sizes, widths and lifetimes must be at least 1.
  • A typo in order, blocklist_action, moderation.on_resolved, media.visibility or media.inline.on_missing throws rather than picking a side — a media.visibility typo no longer reads as private, and an on_resolved typo no longer turns auto-hide off.
  • A non-string bucket or disk name throws, and so does a junk list (blocklist, accepted_mime_types, responsive_widths).

php artisan about renders a broken setting as INVALID instead of failing.

Key type

key_type decides how the polymorphic actor, commentable, mentionable and lockable columns are keyed: bigint (default), uuid or ulid — a blank value (COMMENTS_KEY_TYPE=) is not set and keeps bigint, any other value throws an InvalidConfigurationException. It is read when the migration runs, so set it before you migrate.

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.