musonza/chat
A Laravel package for adding chat and messaging to your application. It provides Eloquent models for conversations, participants, and messages, supports private and group chats, read tracking, events, and a simple API for integrating messaging features.
Install and publish. The package is a plain Composer + vendor-publish install — no service, no credentials.
composer require musonza/chat
php artisan vendor:publish --provider="Musonza\Chat\ChatServiceProvider"
php artisan migrate
Check what got published: config/chat.php and a set of migrations for conversations, messages, participants, and per-participant message notifications. Read those migrations before running them in anything shared — that schema is the real contract you are adopting, not the README.
Wire the participant contract onto your identity models. The package is polymorphic, so anything can be a participant, but it needs a trait/contract:
use Musonza\Chat\Traits\ChatParticipant;
class User extends Authenticatable implements ChatParticipantContract
{
use ChatParticipant;
}
Set user_model in config/chat.php to the model used when the package needs to resolve "the current user" by convention. If you also chat as Team, Agent, etc., add the trait to each — the trait gives you the conversations relation and participant helpers.
First use case to build: a 1:1 DM, end to end. Don't start with group threads or attachments. Prove create → send → list → unread:
use Musonza\Chat\Facades\ChatFacade as Chat;
$conversation = Chat::createConversation([$alice, $bob])->makePrivate();
$message = Chat::message('Hello there')
->from($alice)
->to($conversation)
->send();
// Bob's side
$threads = $bob->conversations()->with('participants')->latest('updated_at')->get();
$messages = Chat::conversation($conversation)->setParticipant($bob)->getMessages(50);
$unread = Chat::conversation($conversation)->setParticipant($bob)->unreadCount();
Chat::conversation($conversation)->setParticipant($bob)->readAllMessages();
Where to look first, in order: config/chat.php → published migrations → src/Models (Conversation, Message, Participant) → src/Models/Conversation methods for addParticipants / removeParticipants / makePrivate / makeGroup → the event classes. The models are short and readable; you will end up reading them more than the docs, because the docs don't cover edge behaviour.
Put a thin service in front of the facade. The facade API is fluent and convenient, but it leaks package model types into controllers, and you'll want one seam if the package ever has to be replaced or forked.
final class Conversations
{
public function open(User $a, User $b): Conversation
{
return Chat::createConversation([$a, $b])->makePrivate();
}
public function post(Conversation $c, User $author, string $body, array $meta = []): Message
{
return Chat::message($body)
->type($meta['type'] ?? 'text')
->data($meta)
->from($author)
->to($c)
->send();
}
}
Authorize before you call the package — every time. The package does not enforce that the actor is a participant. ->to($conversation) will happily write a message for a non-member. Make this a policy, and test it:
Gate::define('post-in-conversation', fn (User $u, Conversation $c) =>
$c->participants()->where('participant_id', $u->id)
->where('participant_type', $u->getMorphClass())->exists()
);
Centralize the participant check in one method and call it from every controller, job, and listener — this is the single most likely source of a data-exposure incident.
Read receipts and unread counts are row counts, not counters. On send, the package inserts a notification row per participant. That's what unreadCount() reads. Embrace it:
// inbox with unread counts without N+1
$threads = $user->conversations()
->with(['participants', 'messages' => fn ($q) => $q->latest()->limit(1)])
->latest('updated_at')
->paginate(20);
For a chat list header, denormalize: a lightweight conversation_summaries cache (last message body, last activity, unread count) refreshed by a listener on the message-sent event. At a few hundred threads per user, aggregate queries will fall over.
Broadcast by listening, not by patching the package. Subscribe to the package's message-sent / participant-added events and dispatch a broadcast event plus a notification job:
Event::listen(MessageWasSent::class, function ($event) {
broadcast(new MessagePosted($event->message))->toOthers(); // or a queued job
NotifyMutedParticipants::dispatch($event->message)->onQueue('chat');
});
Keep the broadcast job ShouldQueue and give it its own queue so a slow socket server can't back up registration emails.
Message types and metadata carry attachments. Rather than adding a column, use the type + JSON data fields:
Chat::message('photo.jpg')->type('image')->data(['url' => $url, 'w' => 800, 'h' => 600])->from($u)->to($c)->send();
Render in the UI by type, and treat data as an untrusted client-supplied blob — validate before sending.
Group chat is the same API with more participants. $conversation->addParticipants([...]), removeParticipants, and promote/demote to admin where you need moderation hooks. Keep membership mutation behind your service so you can fire your own audit log.
Multi-tenancy via global scope, plus your own column. Add a tenant_id to the package tables in your own migration, then apply a global scope on Conversation. The package queries go through Eloquent, so scopes apply — but verify every read path (including unread counts, which join through participants) in a test with two tenants.
Paginate with cursors, always. Message lists are append-only; use before/limit-style paging on created_at+id and never offset at depth. Add a composite index on (conversation_id, created_at) early — retrofitting it on a 20M-row table is a maintenance window.
Test the domain, not the package. RefreshDatabase, create users, assert: non-participant cannot post; unread count goes to zero after readAllMessages; soft-deleted messages disappear from the list and from the count; group add/remove updates membership. Don't rely on vendor factories; write your own fixtures.
Fluent state is sticky. Chat::conversation($c)->setParticipant($u) returns a builder carrying that participant; chaining further calls keeps the context. Reusing one builder instance across users (e.g. in a loop resolving multiple inbox views) is a classic way to read someone else's unread count. Resolve a fresh builder per request/user.
makePrivate() is a state change, not a constructor flag. Create the conversation, then make it private before other participants are added. If you add a third party to a private thread you now have an inconsistent object; guard it in your service.
Morph maps will bite you. Participants are stored with getMorphClass(), not the class name. Enabling Relation::enforceMorphMap() after data exists silently breaks membership lookups and your where('participant_type', ...) checks. Decide once, migrate deliberately, and use $model->getMorphClass() everywhere.
Notification rows grow fastest. Every message fan-outs a row per participant — a 500-member group message is 500 inserts inside your request. Queue large sends, and schedule pruning: delete read notifications older than N days, or archive. Unread counts and inbox queries both live on that table.
Soft-deleted messages still exist. ->remove() soft-deletes; lists exclude them, but counts and "last message" queries need explicit handling. If a conversation's last message is deleted, your denormalized summary must be recomputed, not left blank.
Polymorphic orphans. Delete a User and its participant rows point at nothing — threads show empty member lists and your authorization check may throw. Add deleting/forceDeleting hooks to detach participants and clean notifications.
Config vs. migrations. Table names live in both. If you rename chats/chat_messages in config, check whether the published migrations actually read those keys in your version — in practice you often must edit the migration files too. Rename before first migrate, never after.
Package events are the extension point, not the schema. Adding columns is a fork. Reaching for events/listeners, a summary table, and your own metadata conventions keeps you upgradeable. Record any local patch to vendor code as a numbered diff; upstream merges become manual the moment you fork.
Broadcasting is not included; polling is your safety net. A stalled queue worker leaves messages persisted but undelivered — invisible to the user. Alert on queue depth and offer a poll/refresh fallback in the UI.
Pin and schedule upgrades. Patch releases in this line have been CI/test hygiene only — nothing to do. But budget one package-compatibility cycle per Laravel major, and pin the package version next to laravel/framework in composer.json so a framework bump can't quietly pull an incompatible combination. Track release cadence as a health signal; single-maintainer projects need an internal owner who can read and patch the source.
How can I help you explore Laravel packages today?