How another Flarum 2 extension can react to chat activity, post into channels, change who may do what, add API fields and add UI to the chat. Everything here is a public seam that the chat itself uses.
- Composer package:
ramon/chat
- Extension id:
ramon-chat
- PHP namespace:
Ramon\Chat\
- JS imports:
ext:ramon/chat/…
- Locale namespace:
ramon-chat.*
Overview
Three rules shape every extension point below.
- One write path. User messages go through
Service\MessageDispatcher, which validates, rate-limits, saves, links uploads, syncs mentions and updates unread counters in one transaction. Build on it instead of saving a Message yourself.
- Events after commit. Most events fire once the database write is final, so a listener can safely queue work or call other services. The few that fire inside a transaction are marked below.
- Policies abstain by default. Every chat ability returns
null when it has no opinion, so your policy can add a deny() or allow() without fighting the chat's own.
The chat's own integrations (tags, realtime, mentions, GDPR, audit, fof/upload) are optional. Wire yours the same way: list ramon/chat under suggest in composer.json if your extension also works without it, and gate the extenders with Extend\Conditional.
// extend.php
use Flarum\Extend;
return [
(new Extend\Conditional())
->whenExtensionEnabled('ramon-chat', fn () => [
(new Extend\Event())
->listen(\Ramon\Chat\Event\MessageWasSent::class, Listener\MirrorChatMessage::class),
]),
];
Listening to events
All events live in Ramon\Chat\Event. They are plain classes with public, constructor-promoted properties. $actor is null when the change came from the system: a webhook, a retention job or a bot announcement.
Messages
MessageWasSent — Message $message, ?User $actor — after commit
MessageWasEdited — Message $message, ?User $actor — after save
MessageWasDeleted — Message $message, ?User $actor (soft delete) — after save
MessageWasRestored — Message $message, ?User $actor — after save
MessageWasPurged — int $messageId, Channel $channel, ?int $threadId, ?User $actor (the row is gone) — after delete
ReactionToggled — Message $message, User $actor, string $emoji, bool $added — after save
MessagePinToggled — Message $message, ?User $actor — after save
MessageWasMoved — Message $message, Channel $from, Channel $to, ?User $actor, once per message — inside the transaction
MessagesWereMoved — array $messageIdsBySource, array $sources, Channel $target, array $threadIds, ?User $actor, once per move — after commit
Threads and channels
ThreadWasCreated — Thread $thread, ?User $actor — after commit
ThreadWasEdited — Thread $thread, ?User $actor, only when something changed — after save
ChannelWasCreated — Channel $channel, ?User $actor, also for new direct channels — after commit
ChannelWasEdited — Channel $channel, ?User $actor, also when the image changes — after save
ChannelStatusChanged — Channel $channel, string $previousStatus, ?User $actor — after save
ChannelWasArchived — Channel $channel, Discussion $discussion, ?User $actor — inside the transaction
ChannelWasDeleted — Channel $channel, ?User $actor (soft delete) — after save
ChannelModeratorChanged — Channel $channel, User $user, bool $isModerator, ?User $actor — after save
ChannelRanksChanged — Channel $channel, ?User $actor — after save
Membership, invites and ownership
UserJoinedChannel — Channel $channel, User $user, ?User $actor, bool $hidden, bool $automatic, bool $acceptedInvite, ?User $invitedBy — after save
UserLeftChannel — Channel $channel, User $user, ?User $actor, bool $hidden — after save
UserWasInvited — Channel $channel, User $user, User $inviter, ChannelInvite $invite, once per invite — after save
InviteWasCancelled — Channel $channel, User $user, User $actor, ?User $inviter — after delete
InviteWasDeclined — Channel $channel, User $user, ?User $inviter — after delete
OwnershipTransferRequested — Channel $channel, ChannelTransfer $transfer, User $from, User $to — after commit
OwnershipTransferEnded — Channel $channel, ChannelTransfer $transfer, string $reason, ?User $actor. $reason is DECLINED, CANCELLED or REPLACED; the row is already deleted — after commit
ChannelOwnershipTransferred — Channel $channel, User $newOwner, ?User $previousOwner, ?ChannelTransfer $transfer, ?User $initiator, bool $inherited. $inherited is true for succession, when the owner left — after commit
FlagsChanged — ?User $actor: the moderation queue changed — after save
Bulk joins are silent. Auto-joining a whole group to a channel (MembershipManager::addMany, the auto-join jobs) does not dispatch UserJoinedChannel. Listen to it for joins people make, not as a complete membership log.
Example: mirror messages to an external log
// src/Listener/MirrorChatMessage.php
namespace Acme\ChatMirror\Listener;
use Illuminate\Contracts\Bus\Dispatcher;
use Ramon\Chat\Event\MessageWasSent;
use Acme\ChatMirror\Job\MirrorMessageJob;
class MirrorChatMessage
{
public function __construct(protected Dispatcher $bus)
{
}
public function handle(MessageWasSent $event): void
{
$message = $event->message;
if ($message->isSystem() || $message->channel->isPrivate()) {
return;
}
$this->bus->dispatch(new MirrorMessageJob((int) $message->id));
}
}
Pass ids into jobs, not models or content, and reload the message in the job. A private channel's content must not leave the forum without checking who may read it.
Posting messages from your extension
Inject Ramon\Chat\Service\MessageDispatcher. It is auto-wired, so you can also decorate it with $container->extend().
send(Channel $channel, User $actor, string $content, ?Thread $thread = null, ?Message $replyTo = null, array $uploadIds = [], bool $createThread = false): Message — a message written by a person. Content is parsed by the forum's formatter, so mentions, emoji and BBCode work.
sendSystem(Channel $channel, string $key, array $data = []): Message — a one-line notice with no author, rendered on the client from a translation key.
assertMayRevise(Channel $channel, User $actor, string $content): void — running the same content checks before an edit you perform yourself.
The dispatcher does not check permissions. It validates content, slow mode and rate limits, but not whether the actor may post. Call $actor->assertCan('postMessage', $channel) first, the way the chat's own endpoint does.
use Ramon\Chat\Channel;
use Ramon\Chat\Service\MessageDispatcher;
$channel = Channel::whereVisibleTo($actor)->findOrFail($channelId);
$actor->assertCan('postMessage', $channel);
$message = $this->dispatcher->send($channel, $actor, 'Release 2.4 is out. Notes in the announcements tag.');
System notices
sendSystem() stores a key and data. The client renders it with ramon-chat.forum.message.system.{key}, passing $data as translation parameters, which are inserted as text. Ship the key in your own locale file under the chat's namespace.
$this->dispatcher->sendSystem($channel, 'acme_deploy_finished', [
'version' => '2.4.0',
]);
# locale/en.yml
ramon-chat:
forum:
message:
system:
acme_deploy_finished: "Version {version} was deployed."
Prefix your keys with your extension's name so they never collide with the chat's own: user_joined, user_added, user_accepted_invite, user_left, channel_closed, channel_reopened, channel_archived, discussion_started.
The model factories (Message::build, buildSystem, buildBot, Channel::build, Thread::build) only build an unsaved model. They do not update counters, unread state or mentions and dispatch no events, which is why the dispatcher exists.
Channels, members and invites
MembershipManager — join(Channel, User, ?int $notificationLevel = null, bool $hidden = false), leave(Channel, User, ?User $actor = null), updatePreferences(…), addMany(Channel, array $userIds). No events: dispatch UserJoinedChannel / UserLeftChannel yourself if you want the room to hear about it.
InvitationManager — invite(Channel, iterable $users, User $inviter), accept, decline, cancel, mayEnter(User, Channel).
ChannelArchiver — archive(Channel, User, ?int $discussionId = null, ?string $title = null): Discussion, unarchive(Channel, User).
UnreadTracker — markChannelRead(Channel, User, ?int), totalUnreadFor(User), totalUnreadMentionsFor(User).
ChannelRanks — book(Channel), forUser(Channel, ?int $userId), assign(Channel, User $actor, User $user, array $rankIds). A singleton.
Permissions and policies
The chat registers ChannelPolicy, MessagePolicy, ThreadPolicy and a GlobalPolicy with Extend\Policy. Add your own policy for the same model to tighten or widen an ability. Flarum resolves forceDeny over forceAllow over deny over allow.
- Global —
useChat, startDirect, createChannel
Channel — view, postMessage, join, joinHidden, edit, close, archive, unarchive, delete, manageMembers, manageModerators, manageRanks, transferOwnership, mentionChannelWide, viewMembers
Message — view, edit, delete, forceDelete, restore, react, bookmark, flag, reply, createThread, move, pin, viewRevisions
Thread — view, postMessage, rename
Example: quiet hours for non-moderators
// src/Access/QuietHoursPolicy.php
namespace Acme\QuietHours\Access;
use Carbon\Carbon;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
use Ramon\Chat\Channel;
class QuietHoursPolicy extends AbstractPolicy
{
public function postMessage(User $actor, Channel $channel)
{
if ($channel->isDirect() || $actor->hasPermission('ramon-chat.moderate')) {
return null;
}
return Carbon::now()->hour < 7 ? $this->deny() : null;
}
}
// extend.php
(new Extend\Policy())->modelPolicy(\Ramon\Chat\Channel::class, Access\QuietHoursPolicy::class),
The API resources expose the result as flags (canPostMessage, canEdit, canReact and so on), so the chat UI hides the composer and buttons with no frontend change from you. Webhook deliveries have no actor and do not pass through postMessage.
Permission strings
Check these with $actor->hasPermission(…) when you need the raw grant rather than an ability.
ramon-chat.use (Members) — opening the chat at all
ramon-chat.accessPrivateChannels (none by default) — seeing private category channels
ramon-chat.startDirect (Members) — starting direct messages
ramon-chat.upload (Members) — attaching files
ramon-chat.react (Members) — reactions
ramon-chat.createThread (Members) — starting threads
ramon-chat.sendStickers (Members) — sticker shortcodes (with ramon/stickers)
ramon-chat.flagMessage (Members) — reporting messages
ramon-chat.mentionChannelWide (Moderators) — @here and @all
ramon-chat.bypassSlowMode (Moderators) — ignoring slow mode
ramon-chat.moderate (Moderators) — deleting, moving and restoring messages, the report queue
ramon-chat.inspectChannels (Moderators) — joining private channels hidden
ramon-chat.pinMessage (none by default) — pinning messages
ramon-chat.editChannel (none by default) — editing any channel
ramon-chat.createChannel (Members, in members mode) — creating channels when members own channels
ramon-chat.manageOwnChannels (Members, in members mode) — managing the channels you own
ramon-chat.editOwnChannels (Members, in members mode) — editing the channels you own
Channel visibility is enforced by visibility scopes on Channel, Message, Thread and Upload. Always load chat models with whereVisibleTo($actor); a raw find() skips private channels and tag restrictions.
API resources
The chat is a set of Flarum 2 AbstractDatabaseResource classes, so Extend\ApiResource works on them like on core's.
chat-channels — Api\Resource\ChannelResource — model Channel
chat-messages — Api\Resource\MessageResource — model Message
chat-threads — Api\Resource\ThreadResource — model Thread
chat-uploads — Api\Resource\UploadResource — model Upload
chat-message-flags — Api\Resource\MessageFlagResource — model MessageFlag
chat-webhooks — Api\Resource\WebhookResource — model Webhook (admin only)
Adding a field
// extend.php
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Ramon\Chat\Api\Resource\ChannelResource;
use Ramon\Chat\Channel;
(new Extend\ApiResource(ChannelResource::class))
->fields(fn () => [
Schema\Boolean::make('acmeCanExport')
->get(fn (Channel $channel, Context $context) =>
$context->getActor()->can('acme.exportChat') && ! $channel->isDirect()
),
]),
The channel list endpoint returns up to 50 rows, and the message list up to 100. A query inside ->get() runs once per row. Prefer data already on the model, an eager-loaded relation or a counter column.
Fields the chat adds to core
- Forum:
canUseChat, canCreateChatChannel, canStartChatDirect, canUploadChatFiles, canSendChatStickers, canFlagChatMessages, canModerateChat, canMentionChatChannelWide, canBypassChatSlowMode, canCurateChatChannels, chatOpenFlagsCount.
- User (self only):
chatUnreadChannelsCount, chatUnreadMessagesCount, chatUnreadMentionsCount.
- User relations:
$user->chatChannels and $user->chatMessages.
Filters
Usable as filter[…] on the list endpoints.
/api/chat-channels — type, status, following, q
/api/chat-messages — channel, thread, lessThan, greaterThan, updatedSince, bookmarked, pinned, q
/api/chat-threads — channel, participating
/api/users — chatChannel (members of a channel)
Realtime
With flarum/realtime enabled, the chat sends events to each recipient's private channel, private-user={id}. Recipients are the channel's members who can see it; nothing goes to a public channel.
ramonChat.message — the full message: id, channelId, threadId, contentHtml, user, uploads, mentionedUsers, authorRank and more. Not sent to the author.
ramonChat.messageChanged — same shape, on edit, delete, restore and pin.
ramonChat.messagePurged — {id, channelId, threadId}
ramonChat.reaction — {messageId, channelId, emoji, userId, added}
ramonChat.thread — {threadId, channelId, originalMessageId, title, repliesCount, lastMessageId}
ramonChat.channel — {channelId, status, …}, with created: true or deleted: true on those changes.
ramonChat.membership — {channelId, userId, action, actorId, userCount, …}. action is one of invited, joined, left, invite_declined, invite_cancelled, promoted, demoted, owner_changed, transfer_requested, transfer_ended.
ramonChat.messagesMoved — {channelId, movedOut[]} to sources, {channelId, movedIn[]} to the target.
ramonChat.typing — {channelId, userId, username, typing, expiresIn}
ramonChat.ranks — {channelId, rankBook}
ramonChat.flags — empty; the report queue changed. Sent to moderators.
Sending your own event to a channel's members
Ramon\Chat\Realtime\ChatBroadcaster reuses the same recipient filtering. It does nothing when realtime is not configured, so it is safe to call unconditionally.
use Ramon\Chat\Realtime\ChatBroadcaster;
$this->broadcaster->toChannelMembers($channel, 'acme.pollUpdated', [
'channelId' => (int) $channel->id,
'pollId' => (int) $poll->id,
], exceptUserId: (int) $actor->id);
Send ids and let the client fetch the rest through the API, which applies visibility again. Other methods: toUser(int $userId, string $event, array $payload) and toModerators(string $event, array $payload).
// client
const channel = app.websocket_channels?.user;
channel?.bind('acme.pollUpdated', (data) => {
app.store.find('acme-polls', String(data.pollId)).then(() => m.redraw());
});
Incoming webhooks
Admins create a webhook per channel in the chat settings and get a URL with a secret key. Any service that can send a Slack-style payload can post into the channel.
curl -X POST "https://forum.example.com/api/chat/hooks/<key>" \
-H "Content-Type: application/json" \
-d '{"text": "Build 1842 passed on main."}'
- Text is read from
text, then data.attributes.text, then the attachments[] fields fallback, text, title and pretext. A form body with payload=<json> also works.
- The message has no author, is trimmed to the channel's length limit and fires
MessageWasSent with a null actor.
- Limits: 120 requests per minute per IP and 60 per minute per webhook. The site setting
ramon-chat.webhooks_enabled turns all webhooks off with a 403.
- A successful delivery answers
201 with the new message's id.
For a Flarum extension on the same forum, call MessageDispatcher instead. A webhook is for services outside the forum.
Frontend
Most of the chat UI is in a lazy chunk that loads the first time the chat opens. Flarum 2's extend() and override() accept a module path for that case and apply your change once the module loads.
Example: add a button to every message
// js/src/forum/index.tsx
import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import Button from 'flarum/common/components/Button';
app.initializers.add('acme-chat-translate', () => {
extend('ext:ramon/chat/forum/components/ChatMessage', 'actions', function (vnode, message) {
if (!vnode || message.isSystem() || message.isRedacted()) return;
vnode.children.push(
<Button
className="ChatMessage-action"
icon="fas fa-language"
aria-label={app.translator.trans('acme-chat-translate.forum.translate')}
onclick={() => translate(message)}
/>
);
});
});
actions() returns null when the message has no actions for the viewer, so check the vnode before appending. The chat has no ItemList of its own; the methods below are the places to hook in. All modules are under ext:ramon/chat/forum/components/….
ChatMessage — content, actions, reactions, uploads, avatar, replyPreview, threadIndicator, systemRow, tombstone, editedMark
ChatComposer — placeholder, sendHint, contextBar, pendingAttachments, submit, onKeyDown, insertAtCursor
ChannelView — header, pinnedBar, typingIndicator, scrollDownButton
ChatSidebar — row(channel), createChannel
ChatDrawerPanel — channelMenu, floatingButton, body
ChatSelectionBar — moveControls, quote, copy, move
ChannelInfoModal — content, settings, moderation, memberTab, memberBadge, memberControls
ChatNavButton — view, open (in the main bundle)
These are protected methods of the chat's components rather than a versioned API. Pin ramon/chat to a minor range in your composer.json and test against new releases.
State and models
The shared state is a singleton, used by the drawer, the full page and the realtime handlers.
import chatState from 'ext:ramon/chat/forum/state/chat';
const channel = chatState.channel(12);
await chatState.send(12, 'Standup in five minutes.', { threadId: null });
const stop = chatState.onMembershipChange((channelId) => {
console.debug('membership changed in', channelId);
});
- Store models:
chat-channels, chat-messages, chat-threads, chat-uploads, chat-message-flags, importable from ext:ramon/chat/common/models/…. Their attribute methods mirror the API fields, plus helpers such as Channel.isDirect(), Channel.hasUnread(), Message.isSystem() and Message.mentionsActor().
- Direct messages:
startDirectMessage(user) from the main bundle opens or creates a direct channel with that user.
- Opening the chat:
ChatDrawer.open(), or link to the forum routes /chat, /chat/c/{id} and /chat/c/{id}/t/{threadId}.
- Notifications: the chat's notification types are
chatMention (email), chatChannelInvite, chatChannelInviteDeclined, chatMessageFlagged, chatModeratorPromoted, chatOwnershipTransfer, chatOwnershipTransferDeclined and chatOwnershipInherited.
Formatting
Messages are parsed and rendered by the forum's shared formatter. Anything your extension adds with Extend\Formatter (BBCode, embeds, custom tags) also renders in chat. If rendering throws, the chat falls back to escaped plain text for that message.
Settings and payload
Values the forum serializes for every visitor, read with app.forum.attribute('ramon-chat.title'):
- int —
ramon-chat.maxMessageLength, ramon-chat.minMessageLength
- int, in bytes —
ramon-chat.maxUploadSize
- bool —
ramon-chat.allowUploads, ramon-chat.threadingDefault, ramon-chat.allowArchivingChannels, ramon-chat.showIcon
- string —
ramon-chat.title, ramon-chat.icon, ramon-chat.notificationSound
- string, int or null —
ramon-chat.botName, ramon-chat.botAvatarUrl, ramon-chat.botUserId
Server-only settings, read with SettingsRepositoryInterface: ramon-chat.channel_ownership (admin or members), channel_retention_days, dm_retention_days, max_messages_per_second, message_edit_window_minutes, upload_storage, webhooks_enabled.
User preferences live under ramon-chat.* keys in users.preferences: enabled, sound, emailNotifications, openInDrawer, sendWithCtrlEnter, allowChannelWideMentions.
Deleted user data is handled by the chat's own flarum/gdpr data type (ChatMessages). If your extension stores rows that point at chat messages or channels, register your own GDPR type for them.
Known limits
- No
ItemList in the chat UI. Message actions and channel menus are built as arrays inside methods, so you extend the returned vnode.
- No registry for new realtime event types; push your own with
ChatBroadcaster and bind it on the client.
- A webhook's
username and emoji fields are stored but not yet shown on delivered messages.
- Bulk and automatic joins do not dispatch
UserJoinedChannel.
- Retention pruning removes old messages without
MessageWasDeleted or MessageWasPurged.
Covers the ramon/chat source as of October 2026. Class names, events and payloads were read from the code; method names on UI components may change between minor versions.