Inbox & read receipts
Messages::inboxFor() returns a paginator of the participant’s threads — newest activity first — with the latest message (and its sender), the participants, and a per-thread unread_count eager-loaded, so a chat sidebar renders without N+1 queries:
use RoundlyConsulting\Messages\Facades\Messages;
$inbox = Messages::inboxFor($bob, perPage: 20);
foreach ($inbox as $thread) {
$thread->unread_count; // integer
$thread->latestMessagePreview(); // short, type-aware preview string
}Each thread carries a denormalised last_message_id pointer, so loading the latest message is a single indexed lookup per page rather than a scan of every message in every thread — the inbox stays flat as threads grow. The pointer is kept current automatically on every send, edit, unsend, restore and prune; you never maintain it.
The underlying scope is public too, so you can add your own constraints:
use RoundlyConsulting\Messages\Models\Thread;
// The same query as a scope — add your own constraints on top
Thread::query()->inboxFor($bob)->whereNull('archived_at')->paginate(20);Thread and message helpers
$thread->markReadFor($bob); // same as Messages::markRead($thread, $bob)
$thread->latestMessagePreview(); // null when the thread is empty
$message->isReadBy($bob); // has $bob read up to this message?Read receipts & unread counts
$thread->unreadCountFor($user); // messages $user hasn't read (excludes their own)
$thread->seenBy($message); // participants whose read pointer is at/after $message
$participant->hasUnread();
$participant->markAsRead(); // same as Messages::markRead() for that participantRead state follows each participant’s read pointer, last_read_message_id — the message they had reached when they last marked the thread read. A message is unread when it sorts after that message (by created_at, then by key) or they have never read anything, and they are not its sender; senderless system messages count for everyone. read_at only records when they read, so a reply that lands in the same second as the read still counts as unread. Should the pointer message be pruned later, read_at stands in for it.
Messages in a deleted thread never count — the global unreadCount() matches the inbox.
Marking a thread read
Messages::markRead($thread, $bob); // facade
Messages::thread($thread)->markRead($bob); // thread handle
$bob->markThreadRead($thread); // HasMessaging
$thread->markReadFor($bob); // thread helper
$participant->markAsRead(); // participant row
// all go through the manager: MarkRead runs, ThreadRead fires, the fake records itMarking read moves the pointer to the thread’s newest message and stamps read_at with the current time. Every way above is the same operation — Participant::markAsRead() included — so ThreadRead fires and Messages::fake() records it; markAsRead() throws ParticipationException::participantMissing() when its model no longer exists. Marking read for a model that is not in the thread throws ParticipationException::notAParticipant().
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 cryptoBy 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.