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

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

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

MethodReturnsAction
start(?string $name)PendingThread → create(): ThreadStartThread
to(Thread)PendingMessage → send(?string $body): Message (the sender must participate)SendMessage
direct(Model, Model)ThreadFindOrCreateDirectThread
send(Thread, ?Model $sender, string $body)MessageSendMessage
markRead(Thread, Model)ParticipantMarkRead
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:)ThreadRenameThread / ArchiveThread
thread()->markRead(Model)ParticipantMarkRead
thread()->typing(Model)voidSignalTyping
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)voidRemoveParticipant / LeaveThread
thread()->participants()->setRole(Model, ParticipantRole, by:)ParticipantSetParticipantRole
thread()->participants()->transferOwnership(from:, to:)ParticipantTransferOwnership
message(Message)->edit(string, by:) / delete(by:)MessageEditMessage / DeleteMessage
prune(?int $days, ?Thread)intPruneMessages
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 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.