nvl / comments
Polymorphic threaded comments, reactions, moderation, reports, revisions, and media attachments for Laravel
Requires
- php: ^8.3
- ext-filter: *
- ext-json: *
- ext-mbstring: *
- laravel/framework: ^13.0
- nvl/core: ^2.0
- nvl/filterable: ^2.0
- nvl/media: ^2.0
- nvl/tenancy: ^2.0
- spatie/laravel-data: ^4.23
- spatie/typescript-transformer: ^3.3
- symfony/http-foundation: ^7.2 || ^8.0
- symfony/polyfill-intl-normalizer: ^1.33
Requires (Dev)
- ext-pcntl: *
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- nvl/translatable: ^2.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-26 07:32:43 UTC
README
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/comments:^2.0 |
| Module identifier | nvl/comments |
| PHP namespace | Nvl\Comments |
| Service provider | Nvl\Comments\Providers\CommentsServiceProvider |
| Configuration | config/comments.php |
nvl/comments is a production-oriented, headless comments domain for Laravel
12–13. It provides polymorphic discussion threads, anonymous and authenticated
creation, audience-aware reads, optimistic lifecycle mutations, reactions,
reports, moderation, revision history, and ownership-authorized Media
attachments. It supports PHP 8.4+ and integer, UUID, ULID, or string target
and actor identifiers.
Purpose
Use this package when comments must remain a reusable domain capability rather than becoming controller-owned application logic. It centralizes safe audience projections, canonical target scoping, threaded lifecycle invariants, moderation workflows, and attachment ownership while leaving identity, tenancy, and UI policy in the consuming application.
Boundaries
The package owns comment-domain integrity but does not assume a user model, tenant column, frontend, notification system, Markdown renderer, or moderation UI.
- Comment text is author-owned source content with an optional source locale.
- Plain text and Markdown are stored, not rendered. Consumers must render Markdown with HTML disabled or a strict allowlist.
- Membership and tenancy come from the canonical target plus consumer policy. Never accept a tenant or member scope from request input.
- Public author data is presented through an audience-safe contract. Stored polymorphic actor identities are not public profile data.
- Notification delivery, subscriptions, search, realtime delivery, spam scoring, retention schedules, and UI components belong in consumers and can subscribe to the package's typed events. Comments owns bounded rich-document persistence and normalized current mention references; applications own the registered resource resolvers and their authorization policy.
nvl/comments declares nvl/core, nvl/filterable, and nvl/media; attachment
support is a first-class integration.
Install
composer require nvl/comments:^2.0 php artisan migrate
Laravel auto-discovers
Nvl\Comments\Providers\CommentsServiceProvider. Publish only assets the
consumer needs to own:
php artisan vendor:publish --tag=comments-config php artisan vendor:publish --tag=comments-migrations php artisan vendor:publish --tag=comments-skills
Choose exactly one migration owner:
- Automatic vendor loading (default): leave
comments.migrations.enabled=true, do not publishcomments-migrations, and runphp artisan migrate. - Host-owned published migrations: publish
comments-migrations, setcomments.migrations.enabled=falsebefore migrating, and maintain the published files as application migrations.
Never run both sources. Laravel retimestamps files published through the migration tag. php artisan nvl:comments:doctor reports a warning when automatic loading remains enabled and database/migrations contains a timestamp-independent name matching a package migration; --strict promotes that warning to failure. The bundled create migrations deliberately target the default database connection and canonical table names. If the application configures a different Comments connection or table name, disable the bundled migrations and ship application-owned migrations for that frozen storage layout.
Persistence
Package rows use UUID primary keys. Polymorphic target and actor identifiers are stored as text so common Eloquent key strategies can coexist.
All identity- and classification-sensitive lookups and uniqueness constraints
also use length-delimited SHA-256 fingerprints. This keeps target, actor,
reaction, status, and visibility comparisons byte-exact on databases whose
default text collation is case- or trailing-space-insensitive. Package models
synchronize fingerprints when saved; application-owned bulk imports must
populate them with CommentIdentity or persist through the package models.
commentsstores canonical target/thread structure, content, status, visibility, revision, deterministic counters, idempotency digest, pinning, moderation, deletion, restoration, anonymization, and timestamps.comment_revisionsstores immutable pre-mutation content snapshots.comment_reactionsstores one configured reaction type per comment/actor.comment_reportsstores one reviewable report per comment/reporter.comment_metadata_valuesstores only keyed hashes for registered queryable scalar metadata; the JSON document remains the revisioned source of truth.comment_mentionsstores current ordered mention references by registered alias and opaque application ID. It intentionally has no foreign key to an application resource; revisions retain immutable server-label snapshots.
report_count is a lifetime distinct-reporter count.
open_report_count is the current actionable count. Composite indexes support
target/status queues, pinned thread order, report review, and lifecycle audits.
Soft deletion preserves thread structure; physical parent deletion cascades.
Register a target
Application models may expose the relationship:
use Illuminate\Database\Eloquent\Model; use Nvl\Comments\Contracts\HasComments; use Nvl\Comments\Traits\InteractsWithComments; final class Article extends Model implements HasComments { use InteractsWithComments; }
HTTP target discovery uses allowlisted aliases, never arbitrary class names.
Implement CommentTargetResolver and register it:
'targets' => [ 'article' => ArticleCommentTargetResolver::class, ],
The HTTP alias is only a route-facing allowlist key. Persisted target identity
uses the model's getMorphClass() and key. Register stable Laravel morph aliases
before the first comment is written; when morph maps are enforced, include every
polymorphic model used by the integration, including the package Comment owner
used by Media attachments:
use App\Models\Article; use App\Models\User; use Illuminate\Database\Eloquent\Relations\Relation; use Nvl\Comments\Models\Comment; Relation::enforceMorphMap([ 'article' => Article::class, 'comment' => Comment::class, 'member' => User::class, ]);
Morph aliases and HTTP target aliases are independent even when they share a name. Treat persisted target and actor types as immutable. Changing an existing alias or a fallback class name after data exists breaks canonical lookup and its fingerprints/idempotency references; retaining the original alias is safer than a coordinated application-owned data migration.
Resolvers must return the canonical, policy-scoped target. Actions re-fetch targets and comments by persisted identity, so dirty caller models cannot widen access or replace stored ownership.
The registered target model must define its connection on a fresh model
instance; a caller-only setConnection(...) override is not a registry
contract. Targets may live on another connection: lazy/eager comments
relationships and Comments read Actions remain available, while SQL existence
queries such as whereHas('comments'), has, and withCount require the target
and Comments to share one connection and fail explicitly otherwise.
Cross-connection comment creation must run after the target transaction commits
because no atomic transaction spans both stores. The consuming application must
also coordinate target deletion with comment retention/anonymization; strict
reconciliation diagnoses orphaned target identities but never deletes evidence.
Create comments and replies
Use Actions as the domain boundary:
$actor = new CommentActorData(type: 'member', id: '42'); $comment = $createComment->execute( $article, new CreateCommentData( body: 'A useful observation.', format: CommentFormat::Plain, locale: 'en', idempotencyKey: '2d82413a-5ec8-4e62-a063-73248b05c480', ), $actor, CommentAudience::Member, ); $reply = $createComment->execute( $article, new CreateCommentData( body: 'A bounded reply.', parentId: $comment->id, ), $actor, CommentAudience::Member, );
Actor identities are exact, case-sensitive application identifiers. Anonymous
actors are represented only by CommentActorData::anonymous(), identified
actors require non-blank UTF-8 type and ID values, and trusted system work uses
only CommentActorData::system(). Types are limited to 100 characters and IDs
to 255 characters. fromAuthenticatable() rejects principals whose
authentication identifier is not an integer or string. For an Eloquent
principal it stores getMorphClass(); for another principal it stores the class
name. Prefer an explicit CommentActorResolver with stable application-owned
types, or keep the actor morph alias and principal class stable for the lifetime
of persisted comments.
The generated TypeScript mutation contracts preserve backend omission semantics. Create format, visibility, locale, parent, tags, metadata, and idempotency key are optional; moderation reason/pinned state and report details are optional as well. Omitted HTTP fields receive the same constructor defaults as direct Action callers.
Replies always inherit the canonical target, root, and visibility of their parent. Callers cannot widen or replace those values. Creation enforces the configured depth, content, tag, metadata, actor, target, and visibility rules, increments the direct-parent count once, and dispatches after commit.
Anonymous creation remains available when explicitly enabled and authorized. The public HTTP group accepts public visibility only.
Rich documents and mentions
Rich mutations use CreateRichCommentAction and UpdateRichCommentAction.
They do not change the released plain/Markdown Action or DTO signatures. The
only accepted version-one nodes are paragraph blocks containing text,
hard-break, or mention nodes. Clients submit mention token UUIDs, registered
resource aliases, and opaque IDs only; labels are resolved and stored by the
server. HTML, URLs, client labels, arbitrary fields, and nested children are
rejected.
Rich support is disabled by default through comments.mentions.enabled until
the application registers resources. Declarative Eloquent resources are a
convenience for non-sensitive allowlisted fields; custom resolvers retain
ownership of sensitive domain authorization:
'mentions' => [ 'enabled' => false, 'maximum_per_comment' => 25, 'maximum_resource_types_per_comment' => 10, 'suggestion_limit' => 10, 'maximum_suggestion_limit' => 20, 'maximum_query_length' => 160, 'maximum_batch_size' => 100, 'resources' => [ 'organization' => [ 'model' => Organization::class, 'searchable_fields' => ['name', 'registration_number'], 'exposed_fields' => ['name', 'registration_number'], 'label_field' => 'name', 'authorization' => OrganizationMentionAuthorization::class, 'url_resolver' => OrganizationMentionUrlResolver::class, 'public' => false, ], 'candidacy' => [ 'resolver' => CandidacyMentionResourceResolver::class, ], ], ],
Aliases, searchable/exposed columns, labels, URLs, and authorization are always
server-owned. Declarative queries select only the model key, label, and exposed
fields; escape SQL wildcards; apply the configured authorization scope; order
deterministically; and use bounded parameterized queries. Searchable and exposed
lists each accept at most 25 unique ASCII column names of at most 64 bytes.
Laravel's default guarded = ['*'] sentinel remains compatible with this
explicit mention allowlist, while explicitly guarded and hidden columns remain
forbidden. Custom resolvers
implement CommentMentionResourceResolver. A custom public resolver must also
implement ViewerIndependentCommentMentionResource; otherwise public shared
projections use only immutable label snapshots and never call it.
Call SuggestCommentMentionResourcesAction for an authorized editor and
ResolveCommentMentionsAction for one authorized current comment. Suggestions
return opaque IDs, labels, allowlisted scalar fields, and package-produced URLs.
Every result is capped at 25 fields, 64 bytes per field key, 2,048 bytes per
string field value, and a 2,048-byte relative or HTTP(S) URL. Field strings and
URLs must be valid UTF-8; executable, protocol-relative, control-character, and
non-finite values are rejected at the shared package boundary.
Viewer projections use resolved, missing, or restricted; unavailable
resources expose no live ID, fields, or URL. Projection batches de-duplicate IDs
per alias and preserve token order without N+1 queries. The package bounds
encoded bytes, blocks, nodes, resource aliases, mentions, queries, suggestions,
and resolution batches. body is always a deterministic plain-text projection
for compatibility and search. Raw stored documents and resource identity hashes
are hidden from model serialization; viewer-safe documents never serialize raw
opaque resource IDs.
Declarative Eloquent resolution never performs an unscoped existence lookup:
an absent resource and a resource outside the authorization scope both project
as the same snapshot-only missing state through one scoped query.
CommentMentionsChanged is an after-commit, delivery-agnostic fact containing
only comment/target/revision identity and bounded added/removed alias, opaque ID,
and token facts. Applications own notification recipients, copy, channels, and
delivery.
Applications using custom table names must disable vendor migrations and add
nullable JSON document columns to both comment and revision tables plus a
compatible configured mentions table. Restoring a revision rebuilds current
mention rows from its historical server-label snapshot. Soft deletion retains
them, physical deletion cascades them, and anonymization removes all current
references and rich document state.
Idempotent creation
HTTP callers may send an Idempotency-Key UUID; direct callers use
CreateCommentData::idempotencyKey.
- The same key and canonical payload returns the original active comment or tombstone without replaying counters or events.
- Reusing a key with a different target, actor, parent, visibility, or mutation
payload returns
409 comment_idempotency_conflict. - Lookup includes soft-deleted and anonymized rows. A retry never recreates or restores them.
- Digest equality/conflict is checked before mutable content policy, then an
exact replay rechecks current target
Createor scoped parentView/Replyaccess before returning any representation. - A unique constraint plus reload-after-conflict handles concurrent requests.
- The stored request digest is keyed; raw request payloads are not persisted as an idempotency record.
The digest defaults to app.key. Set
COMMENTS_IDEMPOTENCY_DIGEST_KEY before first use when comment idempotency keys
need an independently managed secret; changing it later invalidates equality
checks for existing keys.
Audiences, query scoping, and authorization
Three explicit audiences prevent accidental contract mixing:
CommentAudience::Public: approved, public, viewer-independent reads.CommentAudience::Member: public rows plus the viewer's policy-scoped rows; the default adds the viewer's own pending, rejected, and private comments.CommentAudience::Management: privileged, target-scoped moderation and evidence reads.
Bind these consumer contracts before enabling authenticated or management APIs:
CommentActorResolverderives a stable actor from the authenticated request.CommentQueryScopereceives the currentCommentAbilityand constrains the canonical target query before filters, sorting, pagination, counts, or identifier resolution for that operation.CommentAuthorization::allows(...)answers capability checks.CommentAuthorPresenterpresents a whole comment batch using display name, avatar URL, label, and an audience-scoped opaque key.
The package's safe presenter never exposes stored actor type or ID. The default authorization/query-scope implementation supports the public contract and author-owned member behavior, but denies management.
CommentQueryScope implementations only add trusted query constraints; they
must not throw authorization denials. Keep denial decisions in
CommentAuthorization/CommentAccessService so an already-authorized mutation
cannot fail afterward while building scoped aggregates.
CommentAbility covers list, view, identity view, create, reply, update,
delete, restore, anonymize, react, report, attach, detach, history, revision
restore, and moderation. ViewIdentity is independent from Moderate; granting
moderation alone never exposes stored actor, reporter, reviewer, or lifecycle
identity. CommentAccessService turns denials into consistent behavior:
inaccessible public/member and cross-target identifiers are 404; management
permission failures may be 403.
Response contracts
PublicCommentDatacontains content, thread structure, safe author presentation, revision, audience-visible counts, aggregate reactions, and timestamps. It contains no status, visibility, actor IDs, report/moderation data, abilities, or storage facts.MemberCommentDataadds status, visibility,isAuthor, viewer abilities, andviewerActivefor each configured reaction.CommentManagementDatacontains moderation/lifecycle state and report aggregates. Stored actor identities are included only after a separateCommentAbility::ViewIdentityauthorization check.CommentAttachmentData,CommentRevisionData, author, abilities, and reaction-summary DTOs provide narrow nested contracts.
Deleted or anonymized public/member comments become structural tombstones. Tombstones retain only ID, root/parent/depth, revision, visible reply count, and timestamps. They omit content, author, reactions, attachments, status, visibility, and mutation abilities.
All configured reaction types appear in configured order, including zero counts. Public payloads expose aggregates only; member payloads additionally expose the current viewer's active state. Reaction actor lists are never part of these contracts.
Author presentation, reaction summaries, visible reply counts (including scoped tombstones), visible attachment counts, and management/member projections are computed in batches. Consumer author presenters and authorization callbacks used during projection must be query-free or perform their own request-scoped batching.
HTTP APIs
Public, member, and management groups are configured and enabled independently:
'routes' => [ 'public' => [ 'enabled' => true, 'prefix' => 'api/v1/discussions', 'name' => 'nvl.comments.public.', 'middleware' => ['api', 'throttle:60,1'], ], 'member' => [ 'enabled' => true, 'prefix' => 'api/v1/member/discussions', 'name' => 'nvl.comments.member.', 'middleware' => ['api', 'auth', 'throttle:60,1'], ], 'management' => [ 'enabled' => true, 'prefix' => 'api/v1/comments', 'name' => 'nvl.comments.management.', 'middleware' => ['api', 'auth', 'throttle:60,1'], ], 'attachments' => [ 'enabled' => true, 'prefix' => 'api/v1/comment-attachments', 'name' => 'nvl.comments.attachments.', 'middleware' => ['api', 'throttle:120,1'], ], ],
The public, member, and management discussion groups are disabled by default.
Opaque signed attachment delivery is enabled while
comments.attachments.enabled is enabled, so independently enabled member or
public APIs can deliver their authorized attachment URLs.
Public and member routes
Both groups independently provide:
GET|POST /targets/{alias}/{id}GET|PUT|PATCH|DELETE /comments/{comment}PUT /comments/{comment}/reactionPOST /comments/{comment}/reportsGET|POST /comments/{comment}/attachmentsDELETE /comments/{comment}/attachments/{association}
{id} is one URL path segment. Laravel's URL generator percent-encodes UTF-8
and spaces, and the package routes accept Laravel's default segment character
set: every character except /. The resolved model key must still be valid,
non-blank UTF-8 with at most 255 characters. An identifier containing /
cannot use these routes; expose an application-owned route with a reversible
slash-free external identifier, then resolve the canonical model before calling
the package Actions.
Member routes additionally provide:
POST /targets/{alias}/{id}/richGET /targets/{alias}/{id}/mentions/{resource}/suggestions?q=...&limit=...PUT|PATCH /comments/{comment}/richPOST /comments/{comment}/restoreGET /comments/{comment}/revisionsPOST /comments/{comment}/revisions/{revision}/restore
The attachment list/create/detach entries in these groups, and the equivalent
management entries below, are omitted when comments.attachments.enabled is
not exactly true.
Public GET responses, including public attachment lists, are evaluated as an
anonymous audience and remain viewer-independent/shared-cache compatible.
Member, management, mutation, revision, signed-asset delivery, and package
error responses are private/no-store. CamelCase is the mutation contract,
including parentId, expectedRevision, idempotencyKey, and mediaId.
Package route middleware normalizes Accept to application/json, so missing
or browser-oriented negotiation cannot turn API authorization and validation
failures into HTML redirects.
Public attachment metadata always uses the configured Media fallback locale, not request/user locale. Its shared-cache lifetime is automatically capped below the embedded signed-URL lifetime with a 30-second safety margin, so a cached list cannot outlive its asset capabilities.
Management routes
Management discovery is always scoped to a canonical target:
GET /targets/{alias}/{id}returns actionable comments.POST /targets/{alias}/{id}/richcreates a rich comment.GET /targets/{alias}/{id}/mentions/{resource}/suggestionsreturns private, authorization-scoped suggestions.PUT|PATCH /{comment}/richupdates a rich comment.GET /targets/{alias}/{id}/reportsreturns actionable reports.PUT /{comment}/moderationPOST /{comment}/restorePOST /{comment}/anonymizeGET /{comment}/attachmentsDELETE /{comment}/attachments/{association}GET /{comment}/revisionsPOST /{comment}/revisions/{revision}/restoreGET /{comment}/reportsPUT /reports/{report}
There is no package endpoint for global cross-target moderation.
Editing, deletion, restoration, and history
Content update, deletion, restoration, anonymization, revision restoration,
comment moderation, and report review require the exact expectedRevision.
Stale mutations raise StaleCommentException.
- Updating snapshots the prior content and increments the revision.
- Deleting soft-deletes the comment, records the responsible actor, increments the revision, and decrements the active direct-parent count once.
- Restoring locks the trashed row, rejects active/anonymized rows and deleted or
anonymized parents, restores to
comments.moderation.restored_status(pendingby default), increments the revision, and increments the parent count once. - Revision history is separately paginated and never embedded in ordinary comment payloads.
- Restoring a revision snapshots the current content, applies the selected snapshot, and creates a new current revision.
Anonymization is terminal and irreversible. It clears comment actor identity, content, locale, tags, metadata, identifying moderation text, stored revision content/identity, and attachment associations. Deletion and restoration actor pairs are also cleared when they match the erased author exactly; other actors' audit identities and lifecycle timestamps remain intact. An active comment is soft deleted and its parent count is adjusted once. Structural target/thread facts and categorical reports/reactions from other actors remain available for authorized audit; any report/reaction owned by the erased comment actor is removed and counters are reconciled. None are exposed through the tombstone. Later restore and moderation attempts are rejected.
Reactions, reports, and moderation
SetCommentReactionAction takes an explicit desired state, so repeated
activation/removal is a no-op and counters remain stable.
ReportCommentAction creates or reopens one report per actor. Lifetime
report_count increments only for a new distinct reporter.
open_report_count increments on create/reopen and decrements on resolve or
dismiss. Report review advances the owning comment revision. Repeating the
same state with the current revision is event- and counter-neutral.
Actionable moderation comments are those in configured review statuses
(pending and spam by default) or with open reports. Queues include
soft-deleted evidence, stay target-scoped, and support allowlisted filters for
status, visibility, deletion/anonymization state, open reports, and bounded
dates. Deterministic sorts cover pinning, creation/update time, open/total
reports, and last report time.
The default policy denies management. Consumers must explicitly authorize moderation and separately authorize exposure of privileged actor identities.
Attachments
Comments use the private, exclusive Media attachments collection. Attaching
requires both Comments authorization and Media authorization for the exact
actor, canonical comment, and canonical Media record. Knowing a Media UUID is
not sufficient.
Media's default Nvl\Media\Contracts\MediaAuthorization is based on public
visibility or the exact uploader identity. Comments reject public Media by
default, so anonymous public attachment projections and non-uploader management
actors cannot receive private attachments under that default policy. Bind a
consumer policy that deliberately grants the required Associate, View,
Download, and Mutate abilities in the canonical Comment owner context;
do not grant blanket access to unrelated private Media.
The package enforces MIME/size/count limits, rejects public Media by default,
and never changes Media visibility. comments.attachments.maximum_file_bytes
defaults to 10 MiB and is enforced at the Action boundary as well as by the
Media slot. Comments, Media, and Media associations must share one database
connection for atomic attachment writes.
When comments.attachments.enabled is exactly false, attachment HTTP routes
are not registered and ordinary comment reads, projections, reconciliation,
and history-free anonymization do not require Media tables. If historical
comment associations still exist, keep the complete same-connection Media
schema available until they are detached or anonymized; strict Doctor treats
incomplete historical attachment state as a deployment failure.
Attachment payloads contain association ID, kind, safe name/metadata, MIME type, size, authorized asset/thumbnail URLs, remove ability, and creation time. They never expose disk, path, checksum, uploader, conversion internals, or raw Media ID. Detach removes only the selected comment association; it never deletes the Media record or another collection. Missing and already-detached associations are idempotent successes.
Asset and thumbnail URLs are short-lived, association-scoped signed routes.
Their path/query contains the association ID only—never the Media UUID,
uploader, disk path, or conversion label. Configure their lifetime with
comments.attachments.signed_url_lifetime. The URL is a bearer capability
authorized against the canonical Comment owner when issued; delivery still
requires a valid signature and a live attachment association. Keep the
attachment delivery routes enabled whenever an HTTP attachment mutation route
is enabled. Mutation preflight returns
503 comment_attachment_delivery_unavailable without writing an association
when signed delivery is not ready.
Attachment and lifecycle operations use one lock order: comment mutation lock, sorted Media locks, then database row locks.
Mutation locking
Mutation locking is enabled and required for production readiness. Configure one canonical Laravel atomic-lock store shared by every HTTP process, queue worker, scheduler, and reconciliation process:
'mutation_lock' => [ 'enabled' => true, 'store' => 'redis', // null uses cache.default 'seconds' => 300, 'wait_seconds' => 30, 'allow_local_store' => false, ],
enabled and allow_local_store must be booleans, store must be null or a
non-blank configured cache-store name, and both timeouts must be positive
integers. Runtime rejects malformed values instead of coercing them.
Use a shared Redis or database cache store in multi-process and multi-node
deployments. The database driver is safe only when every process uses the same
database and the cache lock table is migrated. array, null, and failover
stores cannot preserve one production lock domain and are always rejected.
The file driver is single-host only and is rejected unless
allow_local_store is exactly true; enable that exception only when every
process shares the same host filesystem. Disabling mutation locking makes
strict Doctor unhealthy.
Events
Events are versioned and dispatch only after commit:
CommentChangedusesCommentChangeOperation:created,updated,deleted,restored,anonymized,moderated,report_reviewed, orrevision_restored.CommentReactionChangeddescribes the desired aggregate state.CommentReporteddescribes a report-domain change for authorized listeners.
Idempotent retries, exact no-ops, denials, rollbacks, and reconciliation do not replay user events. Queue listeners should still be idempotent.
Filtering and pagination
ListCommentsAction accepts an allowlisted FilterSet. Trusted audience and
target scopes are applied before caller filters, sorting, pagination, counts,
and identifier resolution. Raw columns and unsupported operators are rejected
by nvl/filterable.
Pagination defaults to 25 and is capped at 100. Root-filtered replies are also
bounded by comments.threading.maximum_replies_per_page. Without a caller
sort, threads are pin-first then newest-first with UUID as the deterministic
tie-breaker. An explicit allowlisted caller sort replaces that default pin
priority and still receives the UUID tie-breaker.
Registered metadata and selectors
Existing JSON metadata remains internal and backward compatible by default.
Applications opt individual scalar fields into validation, equality selectors,
and audience-safe projections by implementing CommentMetadataSchema and adding
the class to comments.metadata.schemas. A schema owns one stable snake/dot
namespace and a list of CommentMetadataField definitions. Fields have a public
snake-case alias, a unique top-level JSON storage key, one of string,
integer, boolean, or uuid, explicit nullability/mutability/queryability,
and an explicit CommentAudience visibility list. Management sees only fields
that declare CommentAudience::Management.
Compatibility mode (comments.metadata.strict=false) validates every registered
field while retaining unknown legacy keys internally. Strict mode rejects an
unknown key on mutation and should be enabled only after Doctor reports
metadata.strict_compatible=true. Metadata has independent encoded-byte and
registered-field limits; it no longer shares the comment body byte limit.
Queryable values are copied to comment_metadata_values as a domain-separated
keyed hash of their type and normalized scalar. The index contains no plaintext
value. Set COMMENTS_METADATA_DIGEST_KEY; otherwise Comments falls back to its
idempotency digest key and then app.key. Rotating that key invalidates lookup
hashes, so run nvl:comments:reconcile --repair to rebuild the index before
depending on metadata selectors.
Use <namespace>.<field> aliases in the selector; raw JSON paths, columns,
operators, unregistered fields, and non-queryable fields are rejected:
$selector = new CommentSelectorData( tags: ['candidacy-workflow'], metadataEquals: [ 'workflow.event' => 'submitted', 'workflow.sequence' => 4, ], status: CommentStatus::Approved, );
At most 10 metadata equalities and 20 tags are accepted. String, integer,
boolean, and explicit null equality use the package-owned hash index on every
supported database; callers never query JSON directly. Safe response metadata
is a list of CommentMetadataProjectionData records containing a namespace and
an allowlisted scalar values record. When no schema value is visible, the
optional property is omitted to preserve existing serialized shapes.
The local package configuration runs this selector contract on SQLite. The
release workflow runs the same CommentMetadataContractsTest.php suite on
PostgreSQL 17, MySQL 8.4, and MariaDB 12.3; those drivers are matrix-only unless
the corresponding DB_CONNECTION service is configured locally.
Metadata is categorical workflow context, not a secret store or rich reference system. Do not place credentials, private tokens, personal secrets, model payloads, or mentions in metadata. Mentions require server-side resolution and authorization and belong to the package's rich-mention contract.
Latest target comment read
Use FindLatestTargetCommentAction when an application needs one newest
comment for a workflow or summary instead of a paginated thread or a direct
Comment query:
use Nvl\Comments\Actions\FindLatestTargetCommentAction; use Nvl\Comments\Data\CommentActorData; use Nvl\Comments\Data\Queries\CommentSelectorData; use Nvl\Comments\Enums\CommentAudience; use Nvl\Comments\Enums\CommentStatus; $comment = app(FindLatestTargetCommentAction::class)->execute( target: $candidacy, actor: CommentActorData::system(), selector: new CommentSelectorData( tags: ['candidacy-workflow'], metadataEquals: ['workflow.event' => 'submitted'], status: CommentStatus::Approved, ), audience: CommentAudience::Management, ); $body = $comment?->body;
Every selected tag must be present. Tags use the same list, distinctness,
UTF-8, and 64-character limits as comment writes, with a hard query cap of 20
or the lower configured write limit; status is an optional CommentStatus.
The Action reloads the target on its declared connection before authorization
and applies CommentQueryScope before these
selectors, excludes soft-deleted comments, orders by created_at then id
descending, and returns the DTO for the requested audience or null.
Management denial occurs before comment SQL, after the canonical target lookup.
Use DeleteLatestTargetCommentAction for
the same bounded selector when a workflow needs to delete its newest match
without receiving a Comment model. Match resolution, authorization, row lock,
and the current-revision delete lifecycle execute in one package transaction.
Latest deletion applies both List and Delete query scopes before selecting a
match and uses the same mutation locks, ordered parent/comment row locks,
revision checks, counter updates, and after-commit event as ordinary deletion.
Operations
Audit package readiness:
php artisan nvl:comments:doctor --strict --format=json
When attachments are enabled, also certify the mandatory Media runtime:
php artisan nvl:media:doctor --production --strict --format=json
Doctor verifies schema columns, production-critical types/lengths/nullability/ defaults, indexes, foreign keys, resolvers, contracts, route completeness, middleware, actor resolution, author presentation, query scoping, authorization readiness, registered metadata schemas/digest/strict compatibility, registered mention resource definitions/hard caps/schema, mutation-lock configuration/topology, and the Comments/Media connection boundary. It also rejects malformed security switches and limits, non-canonical bundled-migration storage configuration, missing fingerprint columns/indexes, and incomplete disabled-attachment history. Enabled public routes require throttling; enabled member and management routes require authentication and throttling. Management additionally requires non-default authorization and query scoping.
Reconciliation validates the current rich document against normalized mention rows and its plain-text body projection, reports invalid snapshots, identity collisions, and orphan rows, and can rebuild current rows/body after stored snapshot revalidation. It never resolves live resources into historical revisions.
Consumer contract checks are structural: Doctor proves that configured classes resolve and that management bindings are not the package defaults, but it cannot prove application-specific tenant, membership, role, author-presentation, or private-Media decisions. Keep application HTTP smoke tests for every enabled audience and representative allowed/concealed actor before deployment.
Audit denormalized state without writing:
php artisan nvl:comments:reconcile --strict
Repair explicitly:
php artisan nvl:comments:reconcile \
--repair \
--target=article:42 \
--chunk=500 \
--strict \
--format=json
--repair additionally requires --force in production. Reconciliation audits
and safely repairs reply, reaction, total-report, open-report, root, depth, and
metadata-index drift. It diagnoses cycles, missing targets, invalid attachment associations,
identity/classification fingerprint drift, and unsafe hierarchy damage without
deleting data. Fingerprint mismatches are never auto-repaired, and they block
counter repair for the affected comment so an ambiguous imported identity
cannot be certified or normalized accidentally. Repairs use the mutation lock,
are safe to repeat after interruption, and emit no user events.
Human output is a table; use --format=json for automation.
Adoption and privacy
For adoption from development or older schemas, back up data and re-migrate the clean schema, or write an application-owned bridge. Do not carry obsolete compatibility shims into production.
For imports, disable automatic migrations, normalize polymorphic identifiers to strings, rebuild root/depth without cycles, map lifecycle states explicitly, preserve lawful history, recompute counters, run reconciliation, then run Doctor.
Do not store secrets in comment bodies or metadata. Define retention, legal erasure, moderator access, and export policies in the consuming application. Soft deletion preserves a thread and is not privacy erasure; use authorized comment anonymization for the package-owned record and an application workflow for actor-wide erasure.
Development and release checks
From a standalone checkout of the public Comments repository:
composer install composer quality
Maintainer CI also runs package-family, dependency, archive, and contract checks from the private source workbench. In a consuming Laravel application, run php artisan nvl:comments:doctor --strict --format=json after configuring the package.
For maintainers with access to the source workbench, the release gate also runs:
php artisan nvl:data:types:generate php artisan nvl:data:types:check composer packages:validate composer contracts:check composer dependencies:check composer validate composer audit --locked --no-interaction
The release matrix additionally runs package and integration Pest suites on SQLite, MySQL 8.4, MariaDB 12.3, and PostgreSQL 17; concurrency coverage; strict Doctor; TypeScript and public-contract checks; clean source and relocated-artifact consumers on Laravel 13; and every supported PHP version.
From the suite root:
composer install composer quality
The development suite uses ext-pcntl for Unix process/concurrency coverage.
Production consumers install with --no-dev and do not require that extension;
run the complete contributor suite on Linux or macOS.
License
Tenant ownership
Comments, revisions, reactions, reports, metadata, mentions, and attachments inherit the canonical target tenant. Global-principal mentions require the host projection contract and active membership; public cache validators and signed attachment capabilities include tenant/site/host identity.
NVL Comments is released under the MIT License.