ozankurt / laravel-modules-chat
Real-time chat for Laravel: rooms, DMs, threads, presence, reactions, attachments, mentions.
Requires
- php: ^8.3
- illuminate/broadcasting: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- ozankurt/laravel-modules-core: ^1.0
- ozankurt/laravel-modules-interactions: ^1.0
- spatie/laravel-medialibrary: ^11.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- filament/filament: ^3.0 || ^4.0 || ^5.0
- filament/spatie-laravel-media-library-plugin: ^3.0 || ^4.0 || ^5.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- laravel/reverb: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- rector/rector: ^2.0
Suggests
- laravel/reverb: First-party WebSocket server for Laravel; recommended Chat transport.
README
Real-time chat module for Laravel: rooms, direct messages, threaded replies, presence, reactions, attachments, and @mentions. Driver-agnostic broadcasting (Laravel Reverb recommended).
Requirements
- PHP 8.4+
- Laravel 12.x or 13.x
ozankurt/laravel-modules-corev2.x
Installation
composer require ozankurt/laravel-modules-chat
Publish config and migrations:
php artisan vendor:publish --tag=chat-config php artisan vendor:publish --tag=chat-migrations php artisan migrate
For real-time delivery, install a broadcaster (Reverb is the recommended one):
composer require laravel/reverb php artisan reverb:install
Concepts
- Conversation — abstract container. Two concrete kinds via
type:Room(type=room) — named, multi-participant, joinable.Direct(type=direct) — exactly two participants. A deterministicdm_key(sorted pair of user keys, e.g.7:12) makesConversation::directBetween($a, $b)idempotent regardless of argument order.
- Message — belongs to a conversation; optional
parent_idfor one-level threaded replies. - Participant — pivot between a user and a conversation, with
role(owner,admin,member) and per-user state (last_read_at,muted_until,notifications). - Reaction —
emoji(unicode or shortcode) by a user on a message; unique per(message, user, emoji).reactWithis idempotent;unreactWithremoves the row. - Attachment — file uploaded via Spatie medialibrary on the
chat-attachmentscollection. - Mention — extracted at
Message::savingand persisted as rows onchat_mentionsfor fan-out + audit. Default resolver matches@usernameand looks up byusernamecolumn; swap viachat.mentions.resolver. - Presence — optional
chat_presencetable with a heartbeat.chat:prune-presencemarks stale rows asoffline.
What it provides
- Models:
Conversation,Participant,Message(HasMedia),Reaction,Mention,Presence. - Enums:
ConversationType,ConversationVisibility,ParticipantRole,ParticipantNotifications,PresenceStatus. Conversation::directBetween($a, $b)— deterministic DM get-or-create viadm_keyunique constraint.Conversation::send($author, $body, ?$parent)— creates the message, bumpslast_message_at, dispatchesMessageSent(which broadcasts onchat.room.{id}orchat.dm.{id}).Message::reactWith($user, $emoji)/unreactWith($user, $emoji)— idempotent toggle pair.Message::scopeRoots()andMessage::scopeInThreadOf($root)for thread queries.- Domain events:
MessageSent,MessageEdited,MessageDeleted($messageId, $conversationId),ReactionAdded($reaction),ReactionRemoved($messageId, $userId, $emoji),UserStartedTyping/UserStoppedTyping,MentionFired($mention). All implementShouldBroadcastNow. - Broadcast channels (
routes/channels.php):chat.room.{id},chat.dm.{id},chat.user.{id},chat.conversation.{id}(presence). Auto-registered viaBroadcast::routes()whenchat.broadcasting.enabled = true(default). - Mention extraction with default
UsernameMentionResolver(regex + DB lookup) and a pluggableMentionResolvercontract for custom strategies. - Observer
MessageObserverenforceschat.message_max_length, extracts mentions, persistschat_mentionsrows, and dispatchesMentionFiredper mention plusMessageEdited/MessageDeletedon update/delete. - Policies:
ConversationPolicy,MessagePolicy(edit window enforced fromchat.edit_window_minutes),ReactionPolicy. A globalcanModerateChatgate bypasses all three. - Console commands:
chat:prune-presence(scheduled every minute) andchat:demo(seeds a room + DM).
Driver-agnostic broadcasting
The package only depends on illuminate/broadcasting. It works with any driver Laravel supports — Reverb, Pusher, Ably, etc. Configure BROADCAST_CONNECTION in your app; the channel callbacks check Participant membership at authorize time.
For tests, broadcasting is never booted — events are asserted via Event::fake([MessageSent::class, ...]) and Event::assertDispatched(...).
REST API
An out-of-the-box JSON REST API built on the Core API kit. It is the history + send + management surface that complements real-time broadcasting - it does not replace it: sending a message over HTTP still dispatches MessageSent, so websocket clients update exactly as they do for a message sent through the domain service directly.
Safe by default. Nothing is registered until you opt in. Set the mode in your app's .env:
CHAT_HTTP_MODE=api
Chat has no public surface, so every endpoint requires an authenticated, participating user: guests get 401, and non-participants get 403. Reads are scoped to the caller's conversations via the chat Policies; all writes are additionally Policy-checked (edit/delete honour chat.edit_window_minutes; add/remove participant and rename require room owner/admin).
Configure the surface under chat.http (published config/chat.php):
| Key | Default | Purpose |
|---|---|---|
mode |
env('CHAT_HTTP_MODE', 'headless') |
headless | api | ui. Routes register only for api/ui. |
prefix |
api/chat |
URL prefix for every route. |
middleware |
['api'] |
Base middleware for the route group. |
auth_middleware |
['auth'] |
Applied to the whole group (chat is never public). Set to e.g. ['auth:sanctum'] for token auth. |
rate_limit |
'60,1' |
maxAttempts,decayMinutes for the chat-api throttle (keyed by user id). |
Responses use the Core envelope: { "data": ..., "meta": ... } for success (paginated lists add meta.pagination), { "message": ..., "errors": ... } for errors.
Endpoints
All paths are relative to the api/chat prefix. Named routes use the chat.api. prefix.
| Method | Path | Action |
|---|---|---|
GET |
conversations |
The authenticated user's conversations (paginated, most-recent-active first; ?filter[type], ?filter[visibility], ?sort). |
POST |
conversations |
Create a room (type=room, name, optional description, participant_ids[]) or resolve/create a direct conversation (type=direct, user_id). |
GET |
conversations/{conversation} |
Show a conversation. |
PATCH |
conversations/{conversation} |
Rename / update a room (owner/admin). |
DELETE |
conversations/{conversation} |
Leave the conversation (removes the caller's participant row). |
GET |
conversations/{conversation}/messages |
Message history, paginated newest-first (?per_page, ?page). |
POST |
conversations/{conversation}/messages |
Send a message (body, optional parent_id) via Conversation::send() - dispatches MessageSent. |
PATCH |
messages/{message} |
Edit own message within the edit window (dispatches MessageEdited). |
DELETE |
messages/{message} |
Soft-delete own message within the edit window (dispatches MessageDeleted). |
POST |
messages/{message}/reactions |
Add a reaction (emoji) - idempotent, dispatches ReactionAdded. |
DELETE |
messages/{message}/reactions |
Remove own reaction (emoji) - dispatches ReactionRemoved. |
POST |
conversations/{conversation}/read |
Mark read up to now, or up to a message via message_id. |
GET |
conversations/{conversation}/unread-count |
Unread message count for the caller. |
GET |
conversations/{conversation}/participants |
List participants (paginated). |
POST |
conversations/{conversation}/participants |
Add a participant (user_id, optional role; owner/admin). |
DELETE |
conversations/{conversation}/participants/{user} |
Remove a participant (owner/admin). |
Direct-message creation and participant add/remove resolve users through the Core UserResolver (kurtmodules.user_model or auth.providers.users.model).
Filament admin
The package ships parallel admin resource sets for Filament v3, v4, and v5 —
ConversationResource, MessageResource, and PresenceResource. The correct
set is chosen at runtime from the installed Filament major, so you register a
single version-dispatching plugin on your panel:
use Filament\Panel; use Kurt\Modules\Chat\Filament\ChatPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(ChatPlugin::make()); }
ChatPlugin::make() resolves to the matching V3/V4/V5 plugin via
Kurt\Modules\Core\Support\FilamentVersion. Install whichever Filament major
your app uses, plus the Spatie media-library plugin (message attachments are
edited through it):
# whichever your app runs composer require filament/filament:"^3.0|^4.0|^5.0" composer require filament/spatie-laravel-media-library-plugin:"^3.0|^4.0|^5.0"
What the resources give you:
- Conversations — type (room/direct) and visibility (public/unlisted/private) enum selects plus name and description; a table with type and visibility badges, participant count, last-message timestamp, and type/visibility filters.
- Messages — a moderation queue: body, type (user/system), conversation
select, an
edited_atpicker and a Spatie media-library upload for thechat-attachmentscollection. The table surfaces soft-deleted messages (theSoftDeletingScopeis dropped) with a trashed filter, a flagged indicator, and delete / restore / force-delete row and bulk actions for moderation. - Presence — a read-only view of the heartbeat table (user, status badge, status message, last heartbeat) with a status filter; no create/edit/delete.
License
MIT (c) Ozan Kurt