magna-cms / message-lite
Plain text conversations for Magna CMS plugins — threads, participants, read state and a cursor-based read API, with no opinion about what a conversation is about.
Package info
github.com/Magna-CMS/message-lite
Type:magna-plugin
pkg:composer/magna-cms/message-lite
Requires
- php: ^8.3
- magna-cms/plugin-sdk: ^1.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 15:43:26 UTC
README
Message Lite is a Magna CMS plugin that provides plain text conversations between people, for any Magna plugin that needs them.
Standalone: no requires in the manifest, and no dependency on any other
plugin. It ships no admin screens or front end of its own (attachments are only
counted in the media library's storage figures) — it is a building block: a PHP
service API for host plugins, plus a JSON API for your front end.
The one idea
Message Lite does not know what a conversation is about. A thread carries an
opaque context_type / context_id, so one host points it at a service request
and the next points it at an order, a ticket, or nothing at all — without either
of them teaching this schema what those words mean.
Everything else follows from that:
- Who may read or write is the host's decision. Message Lite's own rule is
participation — you are in the thread or you are not. A host with richer rules
binds
DecidesParticipation. - There is no endpoint that opens a conversation. Threads are opened by the host in response to something happening. An endpoint taking two user ids and returning a thread would be a way for anybody to write to anybody.
What it does
| Threads | opened by the host, optionally about a context, optionally with a subject |
| Participants | many per thread, each with the host's own word for their role |
| Messages | plain text, with replies, edits and withdrawal |
| Read state | last_read_at per participant, one query for the unread badge and one for the per-thread breakdown |
| Thread lists | previewsFor() — the last message in each of a page of threads, in one query |
| Attachments | files on a message, on a private disk with no public URL, streamed by an authorised endpoint |
| Muting | silence a thread without leaving it |
| Delivery | polling, cursor-based |
Not included, deliberately: reactions, starring, pinning, typing indicators, disappearing messages. This is the lite one.
Requirements
- Magna CMS 1.x (the manifest declares
^1.0) - PHP 8.3+
magna-cms/plugin-sdk^1.0 (installed with Magna CMS)
Installation
From the root of your Magna CMS installation:
composer require magna-cms/message-lite php artisan magna:plugin:enable magna-cms/message-lite
You can also enable it from the admin panel's Plugins screen instead of the
second command. Enabling runs the plugin's migrations, which create the
message_lite_conversations, message_lite_participants,
message_lite_messages and message_lite_attachments tables.
Attachment limits (disk, size cap, files per message, allowed types) come from
the plugin's config/message-lite.php; a
config/message-lite.php in your Magna app overrides them.
Using it from a host plugin
Require it in your manifest:
{ "requires": { "magna-cms/message-lite": "^1.0" } }
Open a thread when something happens — never on request:
use Magna\MessageLite\Services\ConversationService; $conversation = app(ConversationService::class)->open( $serviceRequest, // the context, in your own terms [$clientUserId => 'client', $providerUserId => 'provider'], 'Rewiring the community library', );
For a thread with a context, opening is idempotent on the context and the exact set of people, so a second offer on the same requirement continues the same conversation. Two different providers answering the same requirement get two threads — folding them together would put a client's reply to one in front of the other.
Richer rules
Bind your own participation rules in your plugin's register():
$this->app->bind(DecidesParticipation::class, YourRules::class);
mayRead and mayWrite are asked separately because they genuinely come apart:
an overseer reads without writing, and a finished thread is readable by everyone
in it and writable by nobody.
Nothing is bound by default beyond ParticipationOnly, which is the strict
answer rather than the convenient one — a permissive default would make "we
forgot to configure it" and "we meant everyone to read this" look identical from
the outside.
API
Served at /api/v1/message-lite, authenticated with a Magna API key sent as a
bearer token (Authorization: Bearer …). The routes require a
management-scope key; a delivery key is refused. Each request acts as the
user who owns the key.
GET /conversations your threads
GET /conversations/unread the badge count
GET /conversations/{id} one thread's state
POST /conversations/{id}/read mark read
POST /conversations/{id}/muted mute or unmute
GET /conversations/{id}/messages?after={messageId} poll
POST /conversations/{id}/messages say something
(multipart: attachments[];
throttled to 30/min)
GET /conversations/{id}/messages/{m}/attachments/{a} the bytes
PATCH /conversations/{id}/messages/{message} edit your own
DELETE /conversations/{id}/messages/{message} withdraw your own
Reads are cursor-based: after asks "what has happened since I last looked",
which is one indexed query, where "page one again" is the whole thread every few
seconds.
Decisions worth knowing about
- Withdrawal is a soft delete. The thread reads around the gap rather than closing over it, so replies keep their place and their authors keep their words. A cascade would delete somebody else's message because of a decision they had no part in.
- An edit is stamped. A message that can be rewritten with no trace is one nobody can rely on having read.
- A reply pointing at another thread is dropped, not refused. The message still sends; quoting something its readers cannot see would be worse than quoting nothing.
- The unread cursor is tie-broken on the id. Two messages can share a second, and ordering on the timestamp alone would drop one of them for ever.
- A thread list costs three queries, not three per row.
unreadPerThread()andpreviewsFor()both take a page of ids and answer for all of them at once — a thread list is the screen most likely to grow an N+1, because the per-row version is the one that reads naturally.previewsFor()picks the latest message withMAX(id)rather thanMAX(sent_at): ids are ULIDs and sort in the order they were minted, where a timestamp ties whenever two messages share a second and returns two rows for one thread. - Only your own words. Editing or withdrawing somebody else's message is not a permission this plugin can grant, because there is no reading of it that is not putting words in their mouth.
- Polling, not websockets. Magna has no broadcasting layer; adding one is a core decision, and this plugin works without it.
- An attachment is authorised on its thread, and never has a URL. Whether
you may see a file is entirely a question of whether you may read the
conversation it was sent in — the host's answer, through the same contract.
So the disk has no public URL and the bytes are streamed by a controller that
asks again on every read.
Content-Disposition: attachmentis forced: an HTML or SVG served inline from this origin is a stored XSS against everybody in the thread, which is also why SVG is not on the allowlist. - The stored name is generated and the type is sniffed. A file called
../../.envmust not get to decide where anything lands, and renaming a script to.pdfmust not get it past the allowlist.config/message-lite.phpholds the disk, the size cap and the allowed types. - Whether attachments are offered is the host's call, not this plugin's. The config here is the limits. EMBHAS, for example, has its own operator setting, and this plugin never second-guesses it.
Checks
# from the CMS root
vendor/bin/pint --test plugins-dev/magna-cms/message-lite
vendor/bin/phpstan analyse -c plugins-dev/magna-cms/message-lite/phpstan.neon.dist
vendor/bin/pest plugins-dev/magna-cms/message-lite/tests
The checks run from a Magna CMS checkout with this plugin at
plugins-dev/magna-cms/message-lite.
14 feature tests cover the things that would be expensive to get wrong: thread identity, the unread count, the polling cursor including the shared-second case, participation refusals, a closed thread staying readable, withdrawal leaving replies intact, cross-thread replies, and the absence of a create-conversation endpoint.
License
Message Lite is open-source software released under the MIT License, as declared
in composer.json and magna.json.