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

JSON resources are provided for building a messaging API or an Inertia/SPA backend. They expose only safe fields, render read state and reply info, and degrade gracefully when relations are not eager-loaded:

use RoundlyConsulting\Messages\Facades\Messages;
use RoundlyConsulting\Messages\Http\Resources\MessageResource;
use RoundlyConsulting\Messages\Http\Resources\ParticipantResource;
use RoundlyConsulting\Messages\Http\Resources\ThreadResource;

return ThreadResource::collection(Messages::inboxFor($request->user()));
return MessageResource::collection(Messages::thread($thread)->messages());

Fields

ResourceFields
ThreadResourceid, name, is_direct, is_public, is_archived, last_activity_at, unread_count (when present, e.g. from the inbox), latest_message and participants (when loaded).
MessageResourceid, thread_id, type, body (null once unsent), preview, is_deleted, sender (when loaded, through resources.participant; null for a system message or a deleted sender), reply_to (id, excerpt, sender_id, sender_type; excerpt is the parent’s preview() snapshot, sender_type the quoted sender’s class basename), sent_at, edited_at (text messages only, from meta.edited_at — null unless the text was really edited).
ParticipantResourceid, role, read_at, joined_at, participant (when loaded, through resources.participant; null when the model no longer exists).
MessageParticipantResourceThe default for resources.participant: the model’s participateAs() when it implements ParticipatesInMessaging, otherwise id + type, where type is the class basename (User, never App\Models\User).

Pair ThreadResource with Messages::inboxFor() — the inbox already eager-loads everything the resource reads, including unread_count. Both paginators follow the request’s ?page=, so the envelope’s meta.current_page and links.next stay in step for an infinite-scroll client.

No resource carries a PHP class name. A MessageResource renders like this:

{
  "id": 7, "thread_id": 1, "type": "text", "body": "On it", "preview": "On it", "is_deleted": false,
  "sender": {"id": 4, "name": "Bob"},
  "reply_to": {"id": 6, "excerpt": "First", "sender_id": 3, "sender_type": "User"},
  "sent_at": "2026-10-18T09:30:00+00:00",
  "edited_at": null
}

Participants and senders

participant and sender render through the class in resources.participant, MessageParticipantResource by default: the model’s participateAs() when it implements ParticipatesInMessaging (the {"id": 4, "name": "Bob"} above), otherwise its key and class basename — {"id": 12, "type": "Restaurant"}, never App\Models\Restaurant. To add what your API needs, point the key at your own JsonResource — it receives the participant model:

// config/messages.php
'resources' => [
    'participant' => App\Http\Resources\ChatMemberResource::class,
],

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

MessageParticipantResource::configured() builds the configured resource around a model, or returns null for none. The package resources and the published copies render through it, so one swap reaches both:

use RoundlyConsulting\Messages\Http\Resources\MessageParticipantResource;

MessageParticipantResource::configured($message->sender)?->resolve($request); // null for none

Customising

Publish editable copies — ThreadResource, MessageResource and ParticipantResource in App\Http\Resources\Messages (app/Http/Resources/Messages):

php artisan vendor:publish --tag="messages-resources"

They render exactly what the package’s resources render until you edit them; use them instead of the package classes in your controllers. ThreadResource renders its latest message and participants through your copies too. A MessageResource copy published before 1.1.0 still reads edited_at off updated_at, so an unsent or restored message shows as edited there — copy editedAt() over from the package’s MessageResource.

Copies published by 1.1.x or earlier keep the old output: the morph type — the class name on an app without a morph map — in participant.type (for a model without ParticipatesInMessaging) and reply_to.sender_type, and null for such a sender. Re-publish them with --force, which overwrites your edits, or copy the participant, sender and senderType() code over from the package’s resources.

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.