Group conversations
Messages::start() sets the name, publicity and participants in one chain. The first participant in the list becomes the thread’s owner, everyone else joins as a member:
use RoundlyConsulting\Messages\Facades\Messages;
$thread = Messages::start('Project X')
->public() // ->private(), ->direct()
->everyoneCanJoin()
->withParticipants([$alice, $bob, $carol]) // $alice becomes the owner
->create();
Messages::send($thread, $alice, 'Welcome everyone');From the model
The HasMessaging trait is shorthand over the same manager — startConversationWith() makes the calling model the owner:
$thread = $alice->startConversationWith([$bob, $carol], name: 'Project X');
$alice->sendMessageTo($thread, 'Welcome everyone');
$alice->threads(); // every thread $alice is in, newest activity first
$alice->conversations(); // alias of threads() with a chat-inbox name
$alice->unreadThreads(); // only threads with unread messages
$dave->joinThread($thread); // only when the thread is open to everyonePublic threads and join rules
Group threads carry two flags, is_public and everyone_can_join. When a caller does not set them, they default to publicity.public-by-default and publicity.everyone-can-join (THREADS_PUBLIC and THREADS_EVERYONE_CAN_JOIN). is_public decides the broadcast channel for a new thread and which threads Messages::threads() lists for everyone.
joinThread() is a self-join: it is allowed only on a thread created with ->everyoneCanJoin() (or with publicity.everyone-can-join on) and throws an UnauthorizedMessagingAction otherwise — direct threads are never open. For someone who is already in the thread it is a no-op returning their row. To bring someone into a closed thread, a manager adds them.
Adding and removing participants
Participant changes go through the thread’s participants() handle. Pass by: to enforce roles; omit it for trusted server-side code:
use RoundlyConsulting\Messages\Enums\ParticipantRole;
use RoundlyConsulting\Messages\Facades\Messages;
$participants = Messages::thread($thread)->participants();
// $alice (the owner) adds Dave as an admin — a role above member needs the owner
$participants->add($dave, ParticipantRole::Admin, by: $alice);
// $alice removes Carol — takes a role above Carol's
$participants->remove($carol, by: $alice);
// Bob leaves on his own — no role needed (the owner hands over first)
$participants->leave($bob);Adding someone who is already a participant never creates a second row: add() returns their existing Participant unchanged — no event, no system message, no role change (use setRole() for that). Someone who left and is added again gets a fresh row. Every method accepts the participating model or its Participant row; a row from another thread throws a ParticipationException.
Every new join and every leave touches the thread’s last_activity_at, fires ParticipantJoined or ParticipantLeft, and — with system messages enabled — writes a system message. Removing a model that is not in the thread throws ParticipationException::notAParticipant(), and the owner cannot leave while anyone else is still in the thread — they transfer ownership first (see Roles & permissions).
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.