DI, actions, DTOs & exceptions
The facade is the recommended default, not the only way in. There are three equivalent entry points, and they all run the same code:
- The Messages facade — the shortest calls, as used throughout these docs.
- The manager, RoundlyConsulting\Messages\MessagesManager — the facade’s root, bound as a singleton. Inject it through the constructor for the same API as an explicit dependency, with no static calls.
- The actions under RoundlyConsulting\Messages\Actions — single-purpose classes with execute(…), for composing into your own actions, jobs and commands.
Injecting the manager
use RoundlyConsulting\Messages\MessagesManager;
final class ThreadController
{
public function __construct(private MessagesManager $messages) {}
public function rename(Thread $thread, Request $request): Thread
{
return $this->messages->thread($thread)->rename($request->name, by: $request->user());
}
}Messages::fake() swaps in a MessagesManager subtype, so constructor-injected managers receive the fake too.
Running an action
Each action is container-resolvable, takes a DTO or plain arguments, and dispatches its event:
use RoundlyConsulting\Messages\Actions\EditMessage;
use RoundlyConsulting\Messages\DataTransferObjects\EditMessageData;
app(EditMessage::class)->execute(new EditMessageData($message, 'fixed typo', actor: $alice));Actions called directly bypass Messages::fake(); the facade, the manager and the model traits all go through it.
Facade method → action
| Facade method | Action | execute(…) | Returns |
|---|---|---|---|
start()->create() | StartThread | CreateThreadData | Thread |
direct() | FindOrCreateDirectThread | Model $first, Model $second | Thread |
to()->send() / send() | SendMessage | SendMessageData | Message |
markRead() / thread()->markRead() | MarkRead | MarkReadData | Participant |
thread()->rename() | RenameThread | Thread $thread, ?string $name, ?Model $actor = null | Thread |
thread()->archive() | ArchiveThread | Thread $thread, ?Model $actor = null | Thread |
thread()->typing() | SignalTyping | Thread $thread, Model $participant | void |
participants()->add() | AddParticipant | AddParticipantData | Participant |
participants()->remove() | RemoveParticipant | RemoveParticipantData | void |
participants()->leave() | LeaveThread | Thread $thread, Model $participant | void |
participants()->setRole() | SetParticipantRole | SetParticipantRoleData | Participant |
participants()->transferOwnership() | TransferOwnership | Thread $thread, Model $currentOwner, Model $newOwner | Participant (the new owner) |
message()->edit() | EditMessage | EditMessageData | Message |
message()->delete() | DeleteMessage | Message $message, ?Model $actor = null | Message |
prune() | PruneMessages | PruneMessagesData | int (messages removed) |
unreadCount(), inboxFor(), threads() and thread()->messages() only read, so they have no action.
DTOs
Readonly DTOs under RoundlyConsulting\Messages\DataTransferObjects:
new CreateThreadData(?string $name = null, ?bool $isPublic = null, ?bool $everyoneCanJoin = null, bool $isDirect = false, array $participants = [], ?string $directKey = null);
new SendMessageData(Thread $thread, ?Model $sender, string $body, MessageType $type = MessageType::Text, array $meta = [], int|string|null $parentMessageId = null, array $attachments = [], array $uploads = [], bool $requireParticipation = true);
new EditMessageData(Message $message, string $body, ?Model $actor = null); // actor must be the author
new MarkReadData(Thread $thread, Model $participant);
new AddParticipantData(Thread $thread, Model $participant, ?ParticipantRole $role = null, ?Model $actor = null);
new RemoveParticipantData(Thread $thread, Model $participant, ?Model $actor = null);
new SetParticipantRoleData(Thread $thread, Model $participant, ParticipantRole $role, ?Model $actor = null);
new PruneMessagesData(int $days, int|string|null $threadId = null);Composing actions
use RoundlyConsulting\Messages\Actions\SendMessage;
use RoundlyConsulting\Messages\Actions\StartThread;
use RoundlyConsulting\Messages\DataTransferObjects\CreateThreadData;
use RoundlyConsulting\Messages\DataTransferObjects\SendMessageData;
$thread = app(StartThread::class)->execute(new CreateThreadData(
name: 'Support',
isPublic: false,
participants: [$customer, $agent], // the first participant becomes the owner
));
app(SendMessage::class)->execute(new SendMessageData(
thread: $thread,
sender: $customer,
body: 'My order has not arrived yet.',
meta: ['order' => 'A-1042'],
));SendMessage refuses a sender who is not a current participant unless requireParticipation is false (what withoutParticipationCheck() sets), creates the message and binds its attachments in one transaction, touches the thread’s last_activity_at, and dispatches MessageSent after commit.
Exceptions
All under RoundlyConsulting\Messages\Exceptions; messages are translatable via the messages-translations strings:
| Exception | Named constructor | Thrown when |
|---|---|---|
MessageException | alreadyDeleted() | Editing or deleting a message that is already unsent. |
MessageException | replyAcrossThreads() | A reply targets a message in another thread, or one that does not exist. |
MessageException | notInThread() | Messages::thread($t)->message($m) gets a message of another thread. |
MessageException | attachmentIsNotAnImage() | attachmentPreviewUrl() is called for a non-image attachment. |
ParticipationException | notAParticipant() | Marking read, removing, re-roling, transferring to or signalling typing for a model that is not in the thread. |
ParticipationException | ownershipOnlyByTransfer() | setRole() to or from the owner role, or add() of a second owner — for every caller. |
ParticipationException | ownerMustTransferFirst() | The owner leaves, or is removed, while anyone else is still in the thread — for every caller. |
ParticipationException | notTheOwner() | transferOwnership(from: …) names a participant who does not own the thread. |
ParticipationException | directThreadHasNoRoles() | setRole() or transferOwnership() on a direct thread. |
ParticipationException | inAnotherThread() | A participants() method gets a Participant row of another thread. |
ParticipationException | participantMissing() | A Participant row’s model no longer exists. |
ParticipationException | interfaceImplementationRequired() | A broadcast payload needs participateAs() from a model without ParticipatesInMessaging. |
UnauthorizedMessagingAction | requiresRole() | The actor is not a participant, or lacks the admin role (managing) or the owner role (roles, transfers). |
UnauthorizedMessagingAction | for() | A sender is not a current participant, or the actor may not delete or edit this message, remove a participant of equal or higher rank, or self-join a thread that is not open to everyone. |
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.