The package keeps its own inbox table — one item per recipient, type, subject and context per window, with a count and the latest actor.
Collapsing
The same type + subject + context + recipient within the type’s window() updates one item instead of adding another: count goes up, the latest actor and data replace the old ones, and a read item becomes unread again. The window is fixed from the first event, so a chatty subject starts a fresh item every window rather than collapsing forever.
- No window → every event is its own item.
- An archived or deleted item stops collapsing: the next event starts a new item (an unarchived item stays closed).
- Context is part of the key: the same subject in two contexts stays two items.
- Escalation sends use their own lane: a recipient who got the original receives the escalation as a separate item and mail.
- Lookups include soft-deleted rows, so a deleted item never blocks the next event.
Queries and counts
$inbox = Notifications::inbox($user); // or $user->inbox()
$inbox->paginate(perPage: 20); // not archived, newest activity first
$inbox->cursorPaginate();
$inbox->get(); // capped at inbox.max_per_page
$inbox->first();
$inbox->find($id); // another user's id → InboxItemNotFoundException
$inbox->unread()->paginate(); // read(), archived(), withArchived()
$inbox->in($company)->paginate(); // or a key string: in('company:3')
$inbox->withoutContext(); // allContexts() is the default
$inbox->category('requests')->type('request.awaiting_approval')->since(now()->subDay())->get();
$inbox->unreadCount(); // 7
$inbox->counts(); // InboxCounts { unread, total, contexts: list<ContextCount{key, label, unread}> }Pages eager-load the actor and subject — no N+1 — and perPage is clamped to notifications.inbox.max_per_page.
Mutations
$inbox->markRead($item); // also by id; acknowledges an acknowledgedOnRead() reminder
$inbox->markUnread($item);
$inbox->in($company)->markAllRead(); // one UPDATE within the handle's filters; returns the count
$inbox->markAllRead(in: $company, category: 'requests'); // explicit arguments replace the handle's context / category
$inbox->archive($item);
$inbox->unarchive($item);
$inbox->delete($item); // soft deleteMutators return the handle; markAllRead() returns the count. markAllRead() honours every filter of the handle — in() or withoutContext(), category(), type() and since() — so $inbox->in($company)->markAllRead() clears that workspace only. An item of another recipient is “not found” — the same answer as a missing id, so the inbox is no existence oracle.
Resources
InboxItemResource renders id, type, category, title, body, url (http(s) only), count, data (the type’s publicData()), actor, subject, context, priority ({value, label}), read_at, archived_at, first_event_at and last_event_at (ISO-8601), in the request locale. InboxCountsResource renders InboxCounts.
use RoundlyConsulting\Notifications\Http\Resources\InboxItemResource;
// A page — eager-loads actors and subjects
return InboxItemResource::collection(Notifications::inbox($request->user())->unread()->paginate());
// One item: wrap it yourself — its own data field makes Laravel skip the wrapper
return ['data' => (new InboxItemResource($item))->resolve()];{
"id": 12, "type": "request.awaiting_approval", "category": "requests", "title": "…", "body": null,
"url": "https://app.test/requests/9", "count": 10, "data": {"reference": "PR-9"},
"actor": {"id": 4, "type": "User"}, "subject": {"id": 9, "type": "PurchaseRequest"},
"context": {"key": "company:3", "label": "Acme s.r.o.", "id": 3, "type": "Company"},
"priority": {"value": "normal", "label": "Normal"}, "read_at": null, "archived_at": null,
"first_event_at": "2026-11-09T10:00:00+00:00", "last_event_at": "2026-11-09T10:04:31+00:00"
}No class names over the wire
Actor, subject and context render through swappable resources. Point a key at your own JsonResource to add a name or an avatar; blank or null is the default, anything that is not an instantiable JsonResource subclass throws naming the key, and a null actor or subject renders null. There is no recipient role: no endpoint or event payload renders another user.
| Role | Config key | Receives | Default output |
|---|---|---|---|
actor | notifications.resources.actor | the actor model | {id, type: class_basename} |
subject | notifications.resources.subject | the subject model | {id, type: class_basename} |
context | notifications.resources.context | a ContextReference (snapshot, no query) | {key, label, id?, type?} |
// config/notifications.php
'resources' => [
'actor' => App\Http\Resources\NotificationActorResource::class,
],
// app/Http/Resources/NotificationActorResource.php — receives the actor model
final class NotificationActorResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->resource->getKey(),
'name' => $this->resource->name,
'avatar_url' => $this->resource->avatar_url,
];
}
}Broadcasting (Reverb)
The package never broadcasts; listen to its after-commit events and broadcast the resource yourself:
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Notifications\Events\InboxItemCreated;
use RoundlyConsulting\Notifications\Events\InboxItemUpdated;
use RoundlyConsulting\Notifications\Http\Resources\InboxItemResource;
Event::listen([InboxItemCreated::class, InboxItemUpdated::class], function ($event): void {
$item = $event->item->loadMissing(['actor', 'subject']);
broadcast(new App\Events\InboxChanged(
userId: $item->recipient_id,
item: (new InboxItemResource($item))->resolve(),
unread: Notifications::inbox($item->recipient)->unreadCount(),
));
});
// App\Events\InboxChanged: ShouldBroadcast on new PrivateChannel('App.Models.User.'.$this->userId)
// broadcastWith(): ['item' => $this->item, 'unread' => $this->unread] — the resource only, never a class name.The Laravel inbox channel
A Laravel notification of your own can land in the inbox through the same collapse step. The type’s access check and inbox preference apply; mail is not involved — use Notifications::send() for that:
use Illuminate\Notifications\Notification;
use RoundlyConsulting\Notifications\Mail\InboxMessage;
final class CommentPosted extends Notification
{
public function via(object $notifiable): array { return ['inbox', 'broadcast']; }
public function toInbox(object $notifiable): InboxMessage
{
return (new InboxMessage('comment.posted'))
->about($this->post)
->by($this->author)
->in($this->post->blog)
->with(['excerpt' => $this->excerpt]);
}
}
// Programmatically
Notifications::inboxMessage($user, new InboxMessage('comment.posted')); // ?InboxItemWhen the subject is deleted
notifications.inbox.on_subject_deleted is keep (the default — items render from their snapshot and data), archive or delete. It runs for subjects using HasNotificationReminders; for others, call Notifications::for($subject)->revoke() yourself.
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.