---
title: "DI, actions, DTOs & exceptions — Messages for Laravel | Roundly"
description: "Inject MessagesManager or run the actions yourself — the facade-to-action map, the DTOs they take and the typed exceptions they throw."
url: https://roundly-consulting.com/open-source/docs/messages-for-laravel/actions-dtos
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Messages for Laravel](https://roundly-consulting.com/open-source/docs/messages-for-laravel.md)

# 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

```php
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:

```php
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:

```php
new CreateThreadData(?string $name = null, ?bool $isPublic = null, ?bool $everyoneCanJoin = null, bool $isDirect = false, array $participants = [], ?string $directKey = null);
// $directKey null: StartThread keys a direct thread of 1–2 distinct participants itself (more than 2 throws directThreadTakesTwo()).
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, bool $creatingThread = false);
// $creatingThread: internal — set only by StartThread while it seats a new thread's participants.
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); // $days < 1 → InvalidArgumentException
```

## Composing actions

```php
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. Sends to one thread are serialised: SendMessage locks the thread row before inserting the message, and the thread you passed in ends up holding the latest-message pointer the package stored — not necessarily the message just sent.

## Exceptions

All under RoundlyConsulting\\Messages\\Exceptions; messages are worded in the current locale from the messages-translations strings (English and Slovak ship):

| Exception | Named constructor | Thrown when |
| --- | --- | --- |
| `MessageException` | alreadyDeleted() | Editing or deleting a message that is already unsent, or replying to one. |
| `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` | directThreadIsClosed() | participants()->add() on a direct thread that already exists — for every caller, trusted ones included, and for someone who left. |
| `ParticipationException` | directThreadTakesTwo() | A direct thread is started with more than two distinct participants. |
| `ParticipationException` | inAnotherThread() | A participants() method gets a Participant row of another thread. |
| `ParticipationException` | participantMissing() | A Participant row’s model no longer exists. |
| `ParticipationException` | threadMissing() | Participant::markAsRead() on a participation whose thread was soft-deleted. |
| `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. |

Messages::prune() and the PruneMessages action also throw PHP’s InvalidArgumentException for a retention window below 1 day — before anything is deleted.

## 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/messages-for-laravel.md)
