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

Roles & permissions

Group threads carry per-participant roles — owner, admin, member (RoundlyConsulting\Messages\Enums\ParticipantRole). The participant who starts a thread is its owner; everyone added afterwards joins as a member unless you pass a role. Owners and admins may add and remove participants, rename and archive the thread, and moderate anyone’s messages; members may only manage their own. Changing roles is the owner’s alone — an admin can add members but can neither promote anyone to admin nor demote a fellow admin. Removing someone takes a role above theirs: the owner removes admins and members, an admin removes members only — never a fellow admin or the owner.

use RoundlyConsulting\Messages\Enums\ParticipantRole;
use RoundlyConsulting\Messages\Facades\Messages;

$thread->roleOf($bob);            // ParticipantRole::Member|Admin|Owner|null
$thread->canManage($bob);         // bool

$participants = Messages::thread($thread)->participants();

$participants->setRole($bob, ParticipantRole::Admin, by: $alice);
$participants->transferOwnership(from: $alice, to: $bob); // $alice is demoted to admin

Ownership

Ownership only moves through transferOwnership(), and only from the owner; it demotes the outgoing owner to admin so they keep management rights. This holds for every caller, trusted ones included, and with roles switched off. Each refusal throws a ParticipationException:

  • setRole() never grants the owner role and never changes the owner’s role, and add() never creates a second owner — ownershipOnlyByTransfer().
  • transferOwnership(from: …) refuses a from who is not the owner — notTheOwner().
  • The owner cannot leave, or be removed, while anyone else is still in the thread — ownerMustTransferFirst(). Hand ownership over first; the last one out may simply leave.

Who may do what

OperationWho may (group threads)Call
Add participantsowner, admin — a role above member needs the ownerparticipants()->add(…, by:)
Join on their ownanyone, only on a thread open to everyonejoinThread() / participants()->add($x, by: $x)
Remove someone elsea role above theirs — the owner removes admins and members, an admin members onlyparticipants()->remove(…, by:)
Leave the threadanyone, for themselves — the owner only once ownership is handed over (or they are the last one in)participants()->leave()
Change rolesowner onlyparticipants()->setRole(…, by:)
Renameowner, adminthread()->rename(…, by:)
Archiveowner, adminthread()->archive(by:)
Edit a messageits author only — whatever the roles configmessage()->edit(…, by:)
Unsend a messageits author; owner or admin for anyone’smessage()->delete(by:)
Transfer ownershipowner only — from: must be the owner, for every callerparticipants()->transferOwnership()
Send a messageany current participantto()->from()->send() — withoutParticipationCheck() opts out

Only the author may edit a message — not even an owner or admin, and regardless of messages.permissions.enabled.

Enforcement

Role enforcement is opt-out via messages.permissions.enabled (default true) and is skipped for direct threads, which are always roleless: there, and with roles off, every participant is a peer who may manage the thread. Switching enforcement off stops roles being checked, not being kept — a group thread still gets its owner and members, so turning enforcement on later finds every thread ranked and manageable by its owner. A direct thread has no roles at all: setRole() and transferOwnership() on one throw a ParticipationException.

An actor must always be a participant, though — whatever the config and thread type, someone who is not in the thread (or has left it) cannot send, rename, archive, manage participants, or edit or delete messages in it, including their own.

When an actor is refused, the call throws a typed RoundlyConsulting\Messages\Exceptions\UnauthorizedMessagingAction. Passing no by: actor skips the role check, so trusted server-side code keeps working unchanged; a sender is always checked unless you call withoutParticipationCheck():

use RoundlyConsulting\Messages\Exceptions\UnauthorizedMessagingAction;
use RoundlyConsulting\Messages\Facades\Messages;

try {
    Messages::thread($thread)->rename('Renamed', by: $carol); // $carol is a member
} catch (UnauthorizedMessagingAction $e) {
    // "[App\Models\User:3] requires the admin role to rename the thread."
}

// No actor = trusted server-side code: the role check is skipped
Messages::thread($thread)->rename('Renamed by a job');

With enforcement disabled, canManage() returns true for every participant — and false for anyone who is not in the thread.

Renaming and archiving

Renaming and archiving go through the thread handle:

use RoundlyConsulting\Messages\Facades\Messages;

Messages::thread($thread)->rename('New name', by: $alice);
Messages::thread($thread)->archive(by: $alice);
$thread->isArchived();   // true

RenameThread fires ThreadRenamed with the previous name; ArchiveThread stamps archived_at and fires ThreadArchived once. Archiving does not block new messages — filter or guard archived threads in your app.

Enum helpers

ParticipantRole and MessageType adopt the enums-for-laravel Helpers trait, so both expose readable labels, select-ready option lists and a drift-proof validation rule:

use RoundlyConsulting\Messages\Enums\ParticipantRole;

ParticipantRole::labels();          // ['Owner', 'Admin', 'Member']
ParticipantRole::toOptions();       // ['owner' => 'Owner', 'admin' => 'Admin', 'member' => 'Member']
ParticipantRole::options();         // Collection<EnumOption{ value, label, name }> for JS/Inertia selects
ParticipantRole::validationRule();  // 'in:owner,admin,member'
ParticipantRole::Owner->label();    // 'Owner'

The role enum also carries its domain methods:

ParticipantRole::Admin->canManage();                      // true — owner or admin
ParticipantRole::Owner->isOwner();                        // true
ParticipantRole::Owner->outranks(ParticipantRole::Admin); // true

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.