The Messages facade
Everything is one API in three layers that run the same code: the Messages facade, the injectable MessagesManager behind it, and the action classes that hold the behaviour. The facade is the shortest way in and the recommended default. The Messages alias is auto-registered; import the FQCN to avoid any clash:
use RoundlyConsulting\Messages\Enums\ParticipantRole;
use RoundlyConsulting\Messages\Facades\Messages;
// Start a conversation — the first participant owns a group thread
$thread = Messages::start('Launch')
->public() // ->private(), ->direct(), ->everyoneCanJoin()
->withParticipants([$alice, $bob])
->create();
// Direct messages: one thread per pair, found or created
$dm = Messages::direct($alice, $bob);
// Send
Messages::to($thread)->from($alice)->send('Hello'); // + replyingTo(), withAttachment(), attach()
Messages::send($thread, $alice, 'Hello'); // plain text shortcut
// Read state, inbox and listings
Messages::markRead($thread, $bob);
Messages::unreadCount($bob); // across all threads, or pass a thread
Messages::inboxFor($bob, perPage: 20); // see Inbox & read receipts
Messages::threads($bob, perPage: 25); // $bob's threads + public ones; threads() = public only
// One thread
Messages::thread($thread)->rename('Launch crew', by: $alice);
Messages::thread($thread)->archive(by: $alice);
Messages::thread($thread)->markRead($bob);
Messages::thread($thread)->typing($bob); // broadcast-only, see Broadcasting
Messages::thread($thread)->messages(perPage: 25); // newest first, senders eager loaded
// One message
$message = Messages::send($thread, $alice, 'Helo');
Messages::message($message)->edit('Hello', by: $alice); // author only
Messages::message($message)->delete(by: $alice); // author, or a manager
// Its participants
Messages::thread($thread)->participants()->add($carol, ParticipantRole::Admin, by: $alice);
Messages::thread($thread)->participants()->setRole($carol, ParticipantRole::Member, by: $alice);
Messages::thread($thread)->participants()->transferOwnership(from: $alice, to: $bob); // $alice → admin
Messages::thread($thread)->participants()->remove($carol, by: $bob);
Messages::thread($thread)->participants()->leave($alice); // the owner hands over before leaving
// Housekeeping
Messages::prune(days: 30); // defaults to messages.prune.daysby: is the acting participant; it must be in the thread and is checked against the thread’s roles (see Roles & permissions). Leave it out for trusted server-side calls — no check runs.
A sender must be in the thread
Every send — to()->from()->send(), send(), sendMessageTo(), the SendMessage action — refuses a sender who is not a current participant (never joined, left, or removed) with an UnauthorizedMessagingAction; typing() refuses them with a ParticipationException. A system message has no sender and is always allowed. Trusted server code posting as an outsider — a bot, a support agent — opts out explicitly:
Messages::to($thread)->from($supportBot)->withoutParticipationCheck()->send('We are on it.');Facade methods
| Method | Returns | Action |
|---|---|---|
start(?string $name) | PendingThread → create(): Thread | StartThread |
to(Thread) | PendingMessage → send(?string $body): Message (the sender must participate) | SendMessage |
direct(Model, Model) | Thread | FindOrCreateDirectThread |
send(Thread, ?Model $sender, string $body) | Message | SendMessage |
markRead(Thread, Model) | Participant | MarkRead |
unreadCount(Model, ?Thread) | int | — |
inboxFor(Model, perPage, ?page, pageName) | LengthAwarePaginator<Thread> | — |
threads(?Model $for, perPage, ?page, pageName) | LengthAwarePaginator<Thread> | — |
thread(Thread) | ThreadHandle | — |
thread()->rename(?string, by:) / archive(by:) | Thread | RenameThread / ArchiveThread |
thread()->markRead(Model) | Participant | MarkRead |
thread()->typing(Model) | void | SignalTyping |
thread()->messages(perPage, ?page, pageName) | LengthAwarePaginator<Message> | — |
thread()->message(Message) | MessageHandle | — (refuses other threads) |
thread()->participants()->add(Model, ?ParticipantRole, by:) | Participant (the existing row if already in) | AddParticipant |
thread()->participants()->remove(Model, by:) / leave(Model) | void | RemoveParticipant / LeaveThread |
thread()->participants()->setRole(Model, ParticipantRole, by:) | Participant | SetParticipantRole |
thread()->participants()->transferOwnership(from:, to:) | Participant | TransferOwnership |
message(Message)->edit(string, by:) / delete(by:) | Message | EditMessage / DeleteMessage |
prune(?int $days, ?Thread) | int | PruneMessages |
fake() | MessagesFake | — (see Testing) |
Read-only methods (unreadCount, inboxFor, threads, thread()->messages()) query directly and have no action. The three listings read the current page from the request (?page=, or ?{pageName}=) like Eloquent’s own paginate(); pass page: to pin one explicitly. Prefer injection or single actions? See DI, actions, DTOs & exceptions.
Scoped handles refuse other threads
Messages::thread($thread)->message($message) throws a MessageException when the message belongs to another thread, and every participants() method accepts either the participating model or its Participant row — a row from another thread throws a ParticipationException. Prefer the scoped form whenever the thread and the message both come from the request:
// PATCH /threads/{thread}/messages/{message}
Messages::thread($thread)->message($message)->edit($request->body, by: $request->user());Both handles expose model() to hand back the Thread or Message they are scoped to.
The thread builder
Messages::start() returns a PendingThread:
Messages::start() // the name is optional
->named('Launch planning') // set or change it later
->private() // or ->public()
->withParticipant($alice) // appends — the first participant becomes the owner
->withParticipant($bob)
->create(); // Thread — fires ThreadCreated + ParticipantJoined- named(?string) — set the name (also accepted by start()).
- public() / private() — override the publicity default.
- everyoneCanJoin() — set the everyone-can-join flag.
- direct() — create a direct thread (always a new one).
- withParticipants(iterable) replaces the list; withParticipant(Model) appends to it.
- create() — terminal; returns the Thread.
The message builder
Messages::to($thread) returns a PendingMessage:
use RoundlyConsulting\Messages\Enums\MessageType;
use RoundlyConsulting\Messages\Facades\Messages;
Messages::to($thread)
->from($alice) // the sender (omit for a senderless message)
->withoutParticipationCheck() // trusted code only — let a non-participant send
->ofType(MessageType::Text) // Text (default) or System
->replyingTo($original) // quote a message in the same thread
->withMeta(['client_id' => 'web-42']) // merged into the meta JSON column
->withAttachment($draftToken) // a media-library draft token
->attach($request->file('photo')) // an UploadedFile
->send('On it'); // Message — fires MessageSent- from(Model) — the sender; it must be a current participant of the thread.
- withoutParticipationCheck() — let a sender who is not (or no longer) in the thread post; for trusted server code only.
- ofType(MessageType) — Text (default) or System.
- replyingTo(Message) — reply to and quote a message in the same thread (see Replies & quoting).
- asSystem(string $key, array $params = []) — a translatable system message; clears the sender (see Editing & system messages).
- withMeta(array) — merge arbitrary data into the meta column.
- withAttachment(string) / withAttachments(array) / attach(UploadedFile) — attachments (see Attachments).
- send(?string $body = null) — terminal; returns the Message and fires MessageSent.
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.