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

Editing, deleting & restoring

use RoundlyConsulting\Comments\Facades\Comments;

Comments::update($comment, 'Edited body');                  // dispatches CommentUpdated
Comments::update($comment, 'Edited body', editor: $user);   // names the editor in the edit history
Comments::delete($comment);                // soft delete, dispatches CommentDeleted
Comments::restore($comment);               // restore a soft-deleted comment

Comments::update() re-runs the write rules on the new body: it is trimmed and length-checked, blocked by a subject lock or a locked thread (CommentsLockedException), and run through the blocklist exactly like a write — a match is rejected with the reject action, or moves the comment to pending or hidden. Mentions are re-synced after every edit, and CommentUpdated is dispatched.

Soft deletes

Comments are soft-deleted, so nothing is lost by accident. Comments::delete() dispatches CommentDeleted; Comments::restore() brings the comment back and dispatches no event. Trashed comments stay reachable through Eloquent’s soft-delete scopes:

use RoundlyConsulting\Comments\Models\Comment;

Comment::onlyTrashed()->get();     // soft-deleted comments
Comment::withTrashed()->find($id); // include soft-deleted rows

The action behind update()

Comments::update() runs UpdateCommentAction, which takes an UpdateCommentData DTO — call it directly from a job (see DI and actions):

use RoundlyConsulting\Comments\Actions\UpdateCommentAction;
use RoundlyConsulting\Comments\DataTransferObjects\UpdateCommentData;

app(UpdateCommentAction::class)->execute(new UpdateCommentData(
    comment: $comment,
    body: 'Edited body',
    editor: $user, // optional — recorded on the revision while edit_history is on
));

With authorization enabled, edits check the update ability, deletes the delete ability and restores the restore ability.

Edit history

Turn on comments.edit_history (COMMENTS_EDIT_HISTORY=true) and every edit keeps the body it replaced as a numbered revision in the comment_revisions table, with the editor and the time. It is off by default — an edit then overwrites the body, as before. Name the editor with the editor: argument of Comments::update(), or of UpdateCommentData; without one the revision records no editor:

// .env
COMMENTS_EDIT_HISTORY=true

use RoundlyConsulting\Comments\Facades\Comments;

$comment = Comments::on($post)->as($author)->body('Original')->post();

Comments::update($comment, 'Second', editor: $moderator);
Comments::update($comment, 'Third');            // no editor — the revision records none
Comments::update($comment, '  Third  ');        // same body once trimmed — no revision

$comment->comment;                              // 'Third' — the current body lives on the comment

Read the history with Comments::history($comment) — oldest edit first, each revision with its editor loaded — or the $comment->revisions relation. Each revision holds the body that edit replaced; the current body stays on the comment:

$history = Comments::history($comment);   // oldest edit first, editor loaded

foreach ($history as $revision) {
    $revision->revision;     // 1, 2, 3… in the order the edits landed
    $revision->body;         // the body that edit replaced
    $revision->editor;       // the model passed as editor:, or null
    $revision->editedAt();   // when the edit landed (its created_at)
    $revision->comment;      // the edited comment, as your comments.model class
}

$comment->revisions;         // the same rows as a relation, ordered by revision
  • Revisions are numbered 1, 2, 3… per comment in the order the edits landed. Concurrent edits are serialized by a row lock on the comment inside a transaction, so each one reads the body the previous edit committed — never a stale copy — and every edit lands in the history, in order. A unique index on comment and revision number backs the lock up.
  • An edit that leaves the body as it is (after trimming) records no revision; a refused edit — a blocklist reject, a lock, a denied policy — records nothing.
  • The blocklist still applies to a recorded edit: a match holds the comment at pending or hidden exactly as without history.
  • While the switch is off no lock is taken, and Comments::history() returns an empty collection.
  • CommentResource renders the history as history when revisions is loaded — see API resources.

Deleted comments as placeholders

By default a soft-deleted comment drops out of the public thread — and the replies under it with it. Set comments.deleted_comments (COMMENTS_DELETED_COMMENTS) to placeholder and it keeps its place instead: threadedComments() and approvedComments() still return it, with its replies, at every level:

// .env
COMMENTS_DELETED_COMMENTS=placeholder

Comments::delete($root);

$thread = $post->threadedComments;          // $root is still here, with its replies

$thread[0]->trashed();                      // true
$thread[0]->isPlaceholder();                // true — deleted, and placeholder mode is on
$thread[0]->replies;                        // the live replies under it still load

$post->loadCommentCount()->comments_count;  // the placeholder is not counted
  • $comment->isPlaceholder() is true for a soft-deleted comment while placeholder mode is on; with remove it is always false.
  • Only deleted comments that would otherwise be public stay — a deleted comment that is pending, hidden or visible(false) remains out of the public thread.
  • Placeholders are not counted: loadCommentCount() and withCommentCounts() count live, public comments only.
  • CommentResource renders a placeholder as deleted: true with body and author null and without attachments, likes or history, so nothing it said and nobody who said it leaves the server — see API resources.
  • Any other value than remove or placeholder throws an InvalidConfigurationException naming the key; a blank value is the default, remove.

On the fluent query, withDeleted() keeps soft-deleted comments in the result — top-level and, with withReplies(), in the loaded replies, whichever order you call them in. It works in either mode; render through CommentResource with placeholder mode on to hide the content:

use RoundlyConsulting\Comments\Facades\Comments;

// Keep deleted comments in a query — roots and loaded replies, in any call order:
Comments::for($post)->visible()->rootsOnly()->withReplies()->withDeleted()->get();

// Bulk moderation still acts only on comments that are not deleted:
Comments::for($post)->withDeleted()->hideAll();

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.