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

Drop-in JSON for SPA and mobile backends:

use RoundlyConsulting\Comments\Http\Resources\CommentResource;

return CommentResource::collection(
    $post->threadedComments()->with(['actor', 'likes', 'media'])->get(),
);
FieldContent
idThe comment id.
bodyThe comment body.
statuspending, approved or hidden.
visibleThe visibility flag.
parent_idThe parent comment id, or null.
lockedWhether the comment’s own thread lock is set.
author{ id, type } of the actor through comments.resources.author — type is the class basename (User); null when the author was deleted — only when actor is loaded.
likes{ count, viewer_state, breakdown } — only when likes is loaded.
attachments[{ id, url }] (media UUID + URL, signed for private attachments) — only when media is loaded.
repliesNested CommentResource items — only when replies are loaded.
created_at / updated_atISO 8601 timestamps.

threadedComments() is the public thread, so the JSON holds only visible + approved roots and replies. The resource renders whatever it is given — the replies key is any loaded replies relation — so on public endpoints feed it a public query: threadedComments(), or a visible() CommentQuery with withReplies().

The likes payload is rendered without a viewer, so viewer_state is the guest state — for per-viewer data use withLikedState($viewer) on the query or likeState($viewer). Attachment URLs are resolved by each attachment’s visibility — signed and short-lived for private ones.

The resource leaves out meta, because metadata often holds internal flags. CommentResource is final — to expose meta, render comments through a resource of your own.

A comment loaded with actor and replies renders:

{
  "id": 7,
  "body": "Great post!",
  "status": "approved",
  "visible": true,
  "parent_id": null,
  "locked": false,
  "author": { "id": 42, "type": "User" },
  "replies": [],
  "created_at": "2026-10-18T09:30:00+00:00",
  "updated_at": "2026-10-18T09:30:00+00:00"
}

author renders through CommentAuthorResource: the key and the class basename, never the stored actor_type — without a morph map that is the full class name (App\Models\User), and no PHP class name reaches an API client. type is the basename with or without a morph map; an alias such as member isn’t rendered either.

Your own author shape

Point comments.resources.author at a JsonResource subclass of your own. It receives the actor model, for top-level comments and nested replies alike — load replies.actor for the replies’ authors:

// config/comments.php
'resources' => [
    'author' => App\Http\Resources\AuthorResource::class,
],

// app/Http/Resources/AuthorResource.php
final class AuthorResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->resource->getKey(),
            'name' => $this->resource->name,
            'avatar_url' => $this->resource->avatar_url,
        ];
    }
}

If more than one model type writes comments, branch on the model inside your resource. A blank or null value is the shipped CommentAuthorResource; anything that is not an instantiable JsonResource subclass throws an InvalidConfigurationException naming the key.

Collections & pagination

CommentCollection collects CommentResource and works with paginated results too:

use RoundlyConsulting\Comments\Facades\Comments;
use RoundlyConsulting\Comments\Http\Resources\CommentCollection;

return new CommentCollection(
    Comments::for($post)->visible()->rootsOnly()->withReplies()->paginate(20),
);

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.