---
title: "Inbox & read receipts — Messages for Laravel | Roundly"
description: "A paginated, N+1-free inbox with per-thread unread counts, plus read pointers, seen-by lists and unread checks."
url: https://roundly-consulting.com/open-source/docs/messages-for-laravel/inbox
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Messages for Laravel](https://roundly-consulting.com/open-source/docs/messages-for-laravel.md)

# 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:

```php
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. It is recomputed under the thread’s row lock — on MySQL and MariaDB the newest-message lookup is a locking read too — and sends to one thread are serialised, so two concurrent sends, or an unsend racing a send, never leave it on an older message.

The underlying scope is public too, so you can add your own constraints:

```php
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

```php
$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

```php
$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 participant
```

Read 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

```php
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 it
```

Marking 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 and ParticipationException::threadMissing() when its thread was soft-deleted — where hasUnread() returns false, since participant rows outlive a thread’s soft delete. 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/messages-for-laravel.md)
