This instant chat plan was taken from another application called “profelite”, not from the current Strived LMS: it shows how they built it, so we can take inspiration from it.
The documentation below is unrelated the LMS_Backend, LMS_Frontend projects & has nothing to do with the Backend or the database structure of Strived LMS / E-Learning Application; It has nothing to do with the “https://theteam-13fc7f49.mintlify.site/” documentation as well;
This documentation, System Architecture, Data Model, Eloquent Model, & models below was taken from another project using the similar backend technologies like Laravel.
This Document was taken from the documentation of another project called profElite, this project had some similarity to mine in terms of roles like : admin, instructor and student. the profElite Project had a ReactJs Frontend, so it’s different to what I work with in the frontend.
The documentation below is unrelated the LMS_Backend, LMS_Frontend projects & has nothing to do with the Backend or the database structure of Strived LMS / E-Learning Application; It has nothing to do with the “https://theteam-13fc7f49.mintlify.site/” documentation as well;
This documentation, System Architecture, Data Model, Eloquent Model, & models below was taken from another project using the similar backend technologies like Laravel.
This Document was taken from the documentation of another project called profElite, this project had some similarity to mine in terms of roles like : admin, instructor and student. the profElite Project had a ReactJs Frontend, so it’s different to what I work with in the frontend.
Chat & Messaging System — Full Technical Documentation
Table of Contents
- Overview & Scope
- Technology Stack
- System Architecture
- Data Model
- Eloquent Models
- API Resources (response shapes)
- Complete Route Map
- Controller Reference — Participant Endpoints
- Controller Reference — Admin Endpoints
- Reusable & Private Helper Functions
- Real-Time Layer —
MessageSent+ Channel Authorization - File Sharing — CloudFlare R2 Attachment Pipeline
- The Admin Role — Oversight, Exports & History Wipe
- Moderation & Reporting
- Performance — Measured Response Times
- Security Model
- Error Contract
- Configuration & Environment Variables
- Tests
- Porting Checklist — Duplicating This System
- Known Gaps & Deliberate Decisions
- File Inventory
1. Overview & Scope
The messaging system is a 1-to-1 threaded chat between a student and an instructor, with a read-only admin oversight layer on top. There are no group chats, no channels, and no admin-participant composer — an admin can see, export and destroy history, never write into it. Core identity rule: a conversation is a unique(student_id, instructor_id) pair, enforced by a composite UNIQUE index. POST /conversations is therefore idempotent (firstOrCreate) — calling it twice for the same pair returns the same thread, never a duplicate.
Per-participant state. Unread counters and the archived flag are columns on the conversation, one per side (student_unread/instructor_unread, student_archived/instructor_archived). There is no pivot/participant table. The same thread can be archived by the student while staying active for the instructor, and each side reads its own counter. The API never exposes both — ConversationResource collapses them to a caller-scoped unread / archived based on $request->user().
What the system does:
One controller owns the whole chat surface:
app/Http/Controllers/ConversationController.php (~790 lines, 13 public methods + 4 private helpers). Reports are handed off to ModerationController; uploads to FilesRController.
2. Technology Stack
Deliberately absent: Gates/Policies (roles are integer checks in the controller), soft-deletes on messages (the wipe is a hard delete by design), queues for message delivery (sending is synchronous), notifications on inbound messages, and a WebSocket client in the frontend (see §21).
3. System Architecture
Request lifecycle for every chat endpoint:4. Data Model
4.1 Entity-Relationship Diagram
Two denormalizations are intentional and are what make the conversation list a single cheap query:conversations.last_message+last_message_at— the thread preview, written on every send. The list endpoint never touchesmessages.message_attachments.url/filename/mime_type/size_bytes— copied off theFilerow at link time, so rendering a thread needs no join tofilesand no per-file URL round-trip. Thefile_idFK isnullOnDelete, so an attachment row survives (with its URL) if theFilerow goes.
4.2 Migrations (verbatim schema)
database/migrations/2026_07_01_000013_create_conversations_table.php:
2026_08_06_000002_add_reference_to_conversations_table.php adds the R2 folder key and backfills existing rows:
2026_07_01_000014_create_messages_table.php creates two tables:
2026_07_01_000015_create_message_reports_table.php:
2026_07_01_000002_create_files_table.php (shared with blog/avatars, not chat-specific):
4.3 Index coverage & the one missing index
⚠️ Port this with an index. Every listing in the system sorts onlast_message_at(index,adminIndex,adminXlsx) and that column is unindexed. For a per-user list the row count is small enough that MySQL filesorts a handful of rows, so it is invisible today — butGET /admin/conversationssorts the whole table before paginating, so it degrades linearly with total conversation count. In a new project add$table->index('last_message_at')(or a composite['instructor_id', 'last_message_at']/['student_id', 'last_message_at']) from the start.
5. Eloquent Models
All four modelsuse App\Models\Concerns\SerializesDates, which forces every timestamp to serialize as Y-m-d H:i:s instead of ISO-8601 with microseconds:
App\Models\Conversation
hasParticipant() is the single access-control predicate for the whole subsystem — it is called by 5 controller methods, the broadcast channel callback, and the R2 upload endpoint. Port this one method faithfully.
App\Models\Message
read_at IS NULL is the unread state — there is no boolean. The resource exposes it as read: bool.
App\Models\MessageAttachment
App\Models\MessageReport
conversation(), message(), reportedBy() (reported_by_id), author() (author_id).
App\Models\File (shared, chat-relevant parts)
⚠️ Thedeletinghook does not clean R2. This is the single most important gotcha in the file layer: deleting aFilerow whosetype = 2leaves the object in the bucket forever unless the caller deletes it. BothFilesRController@r2_delete_fileandConversationController@adminWipeMessagesdo so explicitly. If you port this, either replicate the explicit deletes or extend the hook.
Role constants (App\Models\Role)
$user->isStudent() / isInstructor() / isAdmin() — manual checks, no Gates or Policies anywhere in this subsystem.
6. API Resources (response shapes)
Critical envelope rule:AppServiceProvider::boot()callsJsonResource::withoutWrapping(). Single resources and non-paginated collections are therefore returned unwrapped —GET /conversationsandGET /conversations/{id}/messagesreturn plain JSON arrays, not{"data": [...]}. Only hand-built paginated payloads (adminIndex) carry adata/metaenvelope, because the controller writes it by hand. (Verified by executing the endpoints;api_messaging.md§3/§5 shows a{"data": …}wrapper for these two and is wrong on that point —src/hooks/api/messaging.tsin the frontend has it right.)
App\Http\Resources\ConversationResource
Caller-aware — it resolves counterpart / unread / archived from $request->user():
Privacy detail worth copying:counterpart.default_meet_urlis exposed only to the student side ($isInstructorSide ? null : …). The column exists on every user but is only meaningful for instructors, so a student’s own Meet room must never leak to their instructor.
App\Http\Resources\MessageResource
attachmentsis conditional on eager-loading (whenLoaded). Every endpoint that returns messages calls->with('attachments'), so the key is always present in practice — but if you add a new endpoint and forget the eager-load, the key silently disappears from the payload instead of N+1-ing. Keep that; it is the safer failure mode.
App\Http\Resources\MessageReportResource
7. Complete Route Map
All 13 chat routes live inside one middleware group inroutes/api.php:
Route order matters.Full endpoint table:/admin/conversations.xlsxis declared before/admin/conversations/{id}on purpose. Laravel matches in declaration order; the literal.xlsxroute must win, orconversations.xlsxwould be swallowed as an{id}. Preserve this ordering when porting.
Supporting routes (same middleware group, different controllers):
Broadcast channel auth is registered via
bootstrap/app.php → channels: routes/channels.php.
8. Controller Reference — Participant Endpoints
Every method follows the same house pattern:8.1 index(Request $request) — GET /conversations
Lists the caller’s own threads, newest activity first.
- Role branch, not a scope: instructor →
instructor_id, everything else (student and admin) →student_id. An admin hitting this endpoint sees threads where they are the student (normally none) — admins use/admin/conversationsinstead. - Not paginated —
->get()returns every thread. Fine at current scale; see §15. ?archived=1includes the caller’s archived threads. The frontend actually calls it witharchived=1always and filters client-side.
8.2 store(Request $request) — POST /conversations
Idempotent thread creation.
instructor_id, an instructor sends student_id. The caller never supplies their own side, so nobody can open a thread as someone else.
⚠️ Two integrity gaps to fix when porting (present in this implementation):ReturnsBoth are cheap validation rules; the
exists:users,iddoes not check the counterpart’s role. A student can pass another student’s id and create a student↔student thread stored in theinstructor_idcolumn. AddRule::exists('users','id')->where('role_id', Role::INSTRUCTOR)(and the mirror for the instructor branch).- Nothing stops
student_id === instructor_id(a self-thread). Add an explicit guard.UNIQUE(student_id, instructor_id)index does not catch either.
200, not 201 — even on first creation, because firstOrCreate makes the call idempotent.
8.3 messages($id, Request $request) — GET /conversations/{id}/messages
Keyset (cursor) pagination, 50 per page, fixed server-side.
?page=: new inbound messages shift offset-based page boundaries, so a user scrolling up would see duplicates or holes. id < is stable. The trade-off is you can only page backwards — there is no forward cursor, because “forward” is what the broadcast/refetch delivers.
The ->get()->sortBy('id') re-sort happens in PHP on 50 rows (not in SQL) so that the DB can still use the descending index scan with LIMIT 50. Sorting ascending in SQL would mean scanning from the start of the thread.
Page size
50 is hardcoded, mirrored in the frontend as MESSAGES_PAGE_SIZE = 50.
8.4 sendMessage($id, Request $request) — POST /conversations/{id}/messages
The heart of the system. Guard → validate → one transaction (message + attachments + conversation metadata + unread bump) → broadcast → 201.
⚠️ No ownership check onattachment_ids. AnyFileid that exists gets linked, regardless of who uploaded it or whether it belongs to this conversation. A participant could enumerate ids and attach another user’s file (its public URL is then copied into the attachment row). The upload endpoint does check conversation membership (§12), but the link step does not re-check. When porting, add->where('user_id', $me->id)(or verify the key’sfiles/{reference}/prefix) to theFile::find().
⚠️ No validation that at least one ofcontent/attachment_idsis present.POSTwith an empty body creates an empty message (content: null, no attachments) and still bumps the unread counter. Add'content' => 'required_without:attachment_ids'.
8.5 markRead($id, Request $request) — POST /conversations/{id}/read
read_at = now() on the counterpart’s unread messages (a single bulk UPDATE, not a loop). whereNull('read_at') keeps it idempotent and avoids rewriting already-read rows — a repeat call is a no-op and costs one UPDATE matching zero rows.
8.6 archive($id, Request $request) — POST /conversations/{id}/archive
A toggle, not a setter — it flips the caller’s own flag and returns the resulting state:
{"message":"OK","archived":true}. Note this is not idempotent — a retried request un-archives. An explicit {archived: bool} body would be safer in a new implementation.
8.7 report($id, Request $request) — POST /conversations/{id}/report
Files a MessageReport against the other participant, always in status en attente.
author_idis derived, never supplied — you cannot report yourself or a third party.- The report targets the whole conversation;
message_idis an optional pointer and isnullable|integeronly (notexists:messages,id, and not checked to belong to this thread). - Reasons are a controller constant, not a DB enum — the column is a plain
string. Keep the constant as the single source of truth and mirror it in the client’s reason picker. - No rate limiting: a user can file unlimited reports on the same thread. Consider
throttle:or a per-(reporter, conversation) uniqueness rule.
9. Controller Reference — Admin Endpoints
Each of the six admin methods opens with the same inline guard — there is no middleware, no Gate:Note the guard is repeated 6× rather than extracted.ModerationControllerdoes extract it (denyUnlessAdmin(), §14) — that is the better pattern to carry forward, or better still anEnsureUserIsAdminmiddleware applied to an/adminroute group.
9.1 adminIndex(Request $request) — GET /admin/conversations
Paginated oversight list (20/page) with both participants’ contact info.
Theqsearch is a leading-wildcardLIKEacross twowhereHassubqueries — no index can serve it. It is a full scan ofusersper conversation candidate. Acceptable for an admin screen; replace with aFULLTEXTindex or a denormalized search column if the table grows.
9.2 adminShow($id, Request $request) — GET /admin/conversations/{id}
The complete thread (no pagination, no limit) plus contact cards.
messages serializes as a plain array (withoutWrapping).
⚠️ This is the heaviest read in the subsystem and the only unbounded one: a 500-message thread measured ~15 ms and 135 KB of JSON (§15). It grows linearly and forever. In a new project, paginate it the same way the participant endpoint does, or cap it (ModerationController@show already caps its own thread preview at 50).
9.3 adminXlsx(Request $request) — GET /admin/conversations.xlsx
Platform-wide Excel summary, one row per conversation, streamed.
withCount('messages') (avoids N+1 on the counts), chunk(200) (bounded DB memory), and disconnectWorksheets() (PhpSpreadsheet holds circular references; without this call a large export leaks until the request ends).
This replaced a removedFilename:GET /admin/conversations.csv(adminCsv). Don’t reintroduce the CSV variant here — only the finance module keeps both formats.
conversations_YYYYMMDD_HHMMSS.xlsx.
9.4 adminConversationXlsx($id, Request $request) — GET /admin/conversations/{id}/export.xlsx
One thread, one row per message, streamed. This is the export the admin UI runs before a wipe.
conversation-12_leo_at_blondmail.com_2026-10-15.xlsx. The 404 here is French ('Conversation introuvable.'), unlike the participant endpoints’ 'Not found.'.
9.5 adminConversationBackup($id, Request $request) — POST /admin/conversations/{id}/backup.xlsx
Same workbook as §9.4, but pushed to R2 instead of the browser.
conversations_backups/conversation-{id}.xlsx. Re-saving the same thread overwrites (“squashes”) the previous backup rather than piling up dated copies. That is a product decision: one current archive per thread, no storage creep. Say so in any UI copy.
⚠️ Ther2disk is configured'throw' => false, so$disk->put()returnsfalseon failure instead of throwing — this method therefore reports success even if the upload silently failed. When porting, check the return value:if (! $disk->put(...)) { throw new RuntimeException(...); }. ('throw' => falseis deliberate for the shared disk so a failed chat upload doesn’t500; the admin R2 health check clones the config withthrow => truefor exactly this reason.)
9.6 adminWipeMessages($id, Request $request) — DELETE /admin/conversations/{id}/messages
See §13.3 — documented in full with the admin-role flow.
10. Reusable & Private Helper Functions
Five shared pieces carry the subsystem. Port these first; the endpoints are thin on top of them.10.1 loadConversationForExport($id): Conversation — private
The single eager-load shape both exporters need:
attachments.file — the export prints the live File::url, not the denormalized message_attachments.url, so a re-hosted file exports with its current URL.
10.2 buildConversationSpreadsheet(Conversation $c): Spreadsheet — private
Shared by adminConversationXlsx (§9.4) and adminConversationBackup (§9.5), so the downloaded file and the archived one are byte-for-byte identical. Change the layout in one place.
A:J), row 5 blank, row 6 header, rows 7+ one per message oldest → newest. Attachment URLs are space-joined in column J. Sender role is derived (sender_id === student_id → Élève, else Instructeur) rather than read from the user’s role_id, so an exported thread stays correct even if a user’s role later changes.
10.3 adminParticipant(?User $user): ?array — private
The admin contact card, shared by adminIndex and adminShow:
null instead of crashing the list.
10.4 guessMimeType(?string $fileName): ?string — private
Extension → MIME lookup, used when linking an attachment (the byte stream isn’t available at link time, only the stored File row):
null for anything unlisted — the client then falls back to a generic file icon. Note it still maps the video types even though the upload endpoint rejects videos; harmless, but a reminder that this map is a display helper, never a security control.
10.5 Controller::serverError(string $message = …): JsonResponse — base class
catch (\Throwable) in the subsystem is report($e) + return $this->serverError(). $e->getMessage() is never returned — driver text leaks the schema, the SQL, the DB name and host. The real exception goes to storage/logs/laravel.log (or php artisan pail).
10.6 Conversation::hasParticipant(int $userId): bool — the authorization primitive
Already shown in §5. Listed again here because it is the reusable function of this subsystem: called by messages, sendMessage, markRead, archive, report, the conversation.{id} channel callback, and FilesRController@r2_upload_file. Seven call sites, one definition.
11. Real-Time Layer — MessageSent + Channel Authorization
The event
app/Events/MessageSent.php — the only broadcast event in the subsystem:
broadcastAs()pins the client-side event name toMessageSentinstead of the fully-qualified class name — so the frontend listener doesn’t break if the PHP namespace moves.broadcastWith()is self-sufficient: it carries the sender block (name + avatar) and full attachment metadata, so a subscribed client renders the incoming bubble without a follow-up REST call. That is what makes push delivery actually fast rather than “push then fetch”.loadMissing()(notload()) — the sender already eager-loads in the controller (->load('sender','attachments')), so this is a safety net that costs nothing when primed.- The payload shape is deliberately different from
MessageResource(it hassender/created_atwhere the resource hasread/timestamp). If you port this, either unify them or keep both mappings explicit on the client — do not assume the socket payload equals the REST payload. ShouldBroadcast(notShouldBroadcastNow) → the event goes through the queue (QUEUE_CONNECTION=database), so delivery latency depends on a running worker.
Channel authorization
routes/channels.php — registered through bootstrap/app.php (channels: routes/channels.php):
Current transport state
.env.exampleshipsBROADCAST_CONNECTION=log— the payload is written to the log, not delivered. The REST contract is unaffected.- The
REVERB_*env block exists (REVERB_APP_ID/KEY/SECRET/HOST/PORT/SCHEME), so the server side is Reverb-ready; flipping the connection is the only backend change needed. - The frontend has no WebSocket client. There is no
laravel-echo/pusher-jsdependency; the chat page refreshes on window focus instead:There is no polling interval — a message from the other party appears when the tab regains focus or the user re-selects the thread. See §15 for what this means for perceived latency.
Enabling real-time when porting
composer require laravel/reverband publish its config.- Set
BROADCAST_CONNECTION=reverb+ theREVERB_*values; runphp artisan reverb:start. - Run a queue worker (
php artisan queue:work) —ShouldBroadcastis queued. Or switch the event toShouldBroadcastNowto publish synchronously inside the request. - Expose the broadcasting auth route. Laravel 13 does not register
/broadcasting/authautomatically when onlyapirouting is configured — add->withBroadcasting(...)inbootstrap/app.phpwith['middleware' => ['auth:sanctum']], since this API has no session cookie. - Frontend:
npm i laravel-echo pusher-js, configure Echo with the bearer token inauthEndpoint/auth.headers, thenecho.private('conversation.' + id).listen('.MessageSent', cb)— note the leading dot, which is required becausebroadcastAs()sets a custom name.
12. File Sharing — Cloudflare R2 Attachment Pipeline
Attachments never travel over the socket and never pass through the message endpoint as bytes. The client uploads to R2 first, then references the returned file ids.12.1 The two-step flow
12.2 FilesRController@r2_upload_file — POST /api/r2_upload_file
200:
12.3 Object key layout
Key naming:
{userId}_{unixTime}_{sanitizedOriginalName}. The userId_time prefix makes keys effectively collision-free; sanitizeFileName() strips spaces → - and drops anything outside [A-Za-z0-9._-]. Side effect: message_attachments.filename is basename($key), i.e. the prefixed name, so the UI displays 5_1759000000_devoir.pdf rather than devoir.pdf (see §21).
12.4 Limits & the video rule
⚠️ The video guard lives onr2_upload_fileonly.POST /upload_file(withtype = 2) andPOST /r2_bulk_uploadreach the same bucket without it. Chat uploads must keep using/api/r2_upload_file. Don’t “unify” the three endpoints without moving the check.
⚠️ When porting, add a server-side size cap and a positive MIME allowlist. The current design is a deny-list (block videos) with the size limit enforced only in the browser — both are trivially bypassable with curl.'file' => 'required|file|max:40960|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip'would close both holes. Also notefile_get_contents($uploaded->getRealPath())reads the whole file into PHP memory before theput()— with a 40 MB cap that’s 40 MB of RSS per concurrent upload;Storage::disk('r2')->putFileAs(...)streams instead.
12.5 Retrieval
Two flavors exist per storage backend; prefer the URL form:
In practice the chat needs neither:
MessageResource.attachments[].url and the broadcast payload already carry the public URL, so the client renders <img src> / a download link straight from R2.
12.6 CORS — three owners, only one of them is the backend
A recurring “the image won’t load” class of bug. Which layer answers the request decides who sets the CORS headers:
A plain
<img src> is never subject to CORS; only a programmatic fetch() of the bytes is — which is why “it opens in a new tab but breaks in the app” is the classic symptom. POST /admin/storage/r2/test cannot catch a CORS problem: it runs server-side, where CORS does not apply.
13. The Admin Role — Oversight, Exports & History Wipe
13.1 What the admin role is, and is not
Role::ADMIN = 3, checked with $user->isAdmin() inline in each of the six admin methods.
The oversight read path is deliberately REST-only and pull-based: an admin opening a thread is a request, not a subscription, so there is no silent live surveillance channel.
13.2 The archive-before-destroy workflow
Both exports shareloadConversationForExport() + buildConversationSpreadsheet() (§10.1–10.2), so the downloaded file and the R2 archive are byte-for-byte the same workbook.
The admin UI (pages/admin/ConversationDetail.tsx) fetches the .xlsx endpoints through an authenticated blob fetch (downloadXlsx()), not an <a href> — a plain link cannot carry the bearer token.
13.3 adminWipeMessages — the permanent history wipe
DELETE /api/admin/conversations/{id}/messages — a hard delete. No soft-delete, no recycle bin, no undo.
200:
⚠️ Steps 2–3 run outside the transaction, and that is unavoidable — R2 is not transactional. If the request dies between step 3 and step 5, the files are gone while themessagesrows remain, leaving attachment rows pointing at deletedFiles (file_idisnullOnDelete, so they survive with their denormalizedurlpointing at a 404). It fails in the “bytes gone, metadata left” direction, which is the safer one for a delete operation, but it is not atomic. Note this in any port.
⚠️ Step 2–3 is an N+1 by construction: oneSafety rails that exist (all client-side): the admin UI shows aDELETEHTTP call to R2 plus one SQLDELETEper attachment. A thread with 200 attachments makes 200 round-trips to Cloudflare in a single synchronous request. The measured 1.6 ms for a 500-message wipe (§15) had zero attachments — with attachments, R2 network latency dominates completely (~50–200 ms each). Queue this when porting, or at minimum batch the key deletes (Storage::disk('r2')->delete([...keys])accepts an array and issues one multi-delete).
WipeConfirmModal whose copy states the deletion is permanent for both participants (élève et instructeur) and irreversible, the button is disabled on already-empty threads, and the export/backup actions sit next to it. The backend has no confirmation token, no dry-run, and no audit record of who wiped what — see §21.
13.4 Related admin tooling
14. Moderation & Reporting
ModerationController consumes the message_reports rows that ConversationController@report writes. Reports flag the whole conversation, not a single message (the optional message_id is only a pointer).
Status machine
All transitions go through one shared private helper — the pattern to copy:reopen on an already-pending report is a no-op write.
The extracted admin guard (better than ConversationController’s inline copies)
index — GET /admin/reports
Paginated (20) with filters status, reason, q (note / reason / either participant’s name or email), plus stat-card counts in the same response:
COUNT(*) queries per page load — cheap now, worth a single GROUP BY status later.
show — GET /admin/reports/{id}
The report plus conversation context, with the thread preview capped at 50 messages (->orderBy('id')->limit(50)) — the oldest 50, deliberately, since a report is usually about how a thread started. Contrast with adminShow (§9.2), which is uncapped.
pendingCount — GET /admin/reports/pending-count
One COUNT(*), powers the admin sidebar’s live badge.
15. Performance — Measured Response Times
15.1 How these numbers were produced
Measured by instrumenting each endpoint through Laravel’s HTTP test kernel withDB::listen() for query counts and microtime(true) around the dispatch, on this dataset:
- 31 conversations (1 benchmarked + 30 others on the same instructor)
- 500 messages in the benchmarked thread, 120-char bodies, alternating senders
- 0 attachments (so attachment-path costs are called out separately)
- Each endpoint warmed once, then measured; destructive endpoints measured cold (no warm-up)
- PHP 8.4.6 CLI, macOS, SQLite in-memory (MySQL was not running locally)
A realistic production estimate is roughly (measured value) + (queries + 2) × per-query latency + network RTT. For the chat endpoints that lands in the low tens of milliseconds server-side.
15.2 Measured results
15.3 Reading the numbers
The query counts are the portable fact — they hold on any database, and they are flat:- Every read is 3–5 queries, constant in data size. No N+1 anywhere on the read paths:
->with(['student.image','instructor.image'])is 2 extra queries for 1 or 1000 rows;->with('attachments')is 1 more;withCount('messages')is an aggregated subquery, not a loop. POST messagecosts 9 queries — the most of any chat endpoint. Breakdown:SELECTconversation →INSERTmessage →UPDATEconversation → then the broadcast’sload('sender','attachments')+sender->image+ the response’sload('attachments'). Roughly 4 of the 9 queries exist only to build the broadcast payload — and withBROADCAST_CONNECTION=logthey are spent on a log line nobody reads. Switching to Reverb makes them useful; switching the event off would make the send noticeably leaner.markReadis cheap and self-limiting: one bulkUPDATE … WHERE read_at IS NULL, so 250 unread messages cost the same single statement as 1, and a repeat call drops to 2 queries matching 0 rows.archiveis 2 queries —SELECT+UPDATE.- Keyset pagination is flat:
?before=300costs exactly what the first page costs (2.0 vs 2.2 ms), which is the whole point ofWHERE id < ?overOFFSET. Paging to message 10 000 will be the same.
GET /admin/conversations/{id}— 15.5 ms / 135 KB for 500 messages. Only 5 queries, so this is not a query problem: it is hydrating 500 Eloquent models and serializing 500 resources. Cost is O(messages in thread), unbounded. A 5 000-message thread would be ~150 ms and ~1.3 MB. This is the endpoint to paginate first in any port.GET /admin/conversations/{id}/export.xlsx— 74.7 ms for 500 rows. ~0.15 ms per message, nearly all of it inside PhpSpreadsheet (fromArray+setAutoSize(true)on 10 columns, which measures every cell’s rendered width). A 5 000-message thread → ~700 ms; 20 000 → several seconds and real memory pressure. Streaming only helps the transfer, not the build: the whole workbook is assembled in memory first. If threads can get long, queue the export or dropsetAutoSize.POST /admin/conversations/{id}/backup.xlsx— not separately timed because it is dominated by network, but it is the export build (above) + a temp-file write + an R2PUTof the whole workbook, all synchronous. Budget the export time plus R2 upload latency.
DELETE HTTP request and one SQL DELETE per file:
max_execution_time. Batch the key deletes and/or queue the job.
Perceived chat latency is not an API problem. Server-side the send is ~2 ms, but BROADCAST_CONNECTION=log means nothing is pushed and the frontend has no WebSocket client and no polling interval — it refetches only on window.focus. So the other participant sees a new message when they refocus the tab, not in real time:
The
MessageSent payload is deliberately self-sufficient (§11), so enabling Reverb adds zero extra API calls per delivered message.
15.4 Scaling notes for a port
16. Security Model
Layered access control
Layer 2 (app/Http/Middleware/EnsureUserIsNotBanned.php, aliased in bootstrap/app.php):
What is guarded well
- Thread access — one predicate (
hasParticipant), seven call sites, including the broadcast channel and the upload endpoint. No role can read a thread it is not in (admins go through separate routes). storecannot be spoofed — the caller’s own side is taken from$me->id, never from the body.reportcannot be aimed —author_idis derived from the thread, not supplied.- Upload folder isolation —
r2_upload_fileverifieshasParticipant() || isAdmin()before using a conversation’sreferenceas the R2 folder, so nobody can write into another thread’s folder. Covered by a test (§19). - No driver detail in errors —
serverError()+report($e);$e->getMessage()never reaches a client on these routes. - Counterpart privacy —
default_meet_urlis student-only; participants never get each other’s email/phone (only admins do).
What to harden when porting
17. Error Contract
The
422 shape comes from the house pattern:
Three inconsistencies to normalize in a port: the404message language differs between export and non-export endpoints;422bodies sometimes carryerror: trueand sometimes don’t; and the flatten-to-first-message approach discards per-field errors, so a client cannot highlight the offending input. Returning Laravel’s standard{message, errors:{field:[…]}}is friendlier — but note this API’s French validation strings are part of the contract: the frontend’sauthErrorMessagesubstring-matches them.
18. Configuration & Environment Variables
r2 disk (config/filesystems.php):
⚠️R2_URLmust be set —r2Url()builds every stored URL asrtrim(env('R2_URL'),'/').'/'.$key. With it empty, uploads succeed and every storedurlis a broken/files/...path. Also noter2_upload_filereadsenv()directly rather thanconfig(): afterphp artisan config:cache(which the production entrypoint runs)env()returnsnull. Route these throughconfig/when porting.
⚠️PHP extensions required at runtime:throw => falseis deliberate on the shared disk so a failed chat upload doesn’t500— but it meansput()/delete()returnfalseinstead of throwing. Check return values (§9.5).
zip, xml, gd (PhpSpreadsheet). Missing them breaks the exports at runtime, not at build time.
19. Tests
tests/Feature/ChatAttachmentFolderTest.php is the one chat-specific test suite. It covers the R2 folder isolation rule:
Setup pattern worth reusing (no factories exist in this project — seed explicitly):
403s, the admin 403s, the before= cursor, and above all the wipe (that message rows, attachment rows, File rows and R2 objects all disappear and the conversation row survives with reset counters).
20. Porting Checklist — Duplicating This System
Build in this order; each step is independently testable.Phase 1 — schema
-
conversations+UNIQUE(student_id, instructor_id)+index('last_message_at')(§4.3) -
referencecolumn,string(40),unique, nullable (populated by the model hook) -
messages(contentnullable,read_atnullable,kindstring,bookingjson) -
message_attachments(file_idnullablenullOnDelete, denormalizedurl/filename/mime_type/size_bytes) -
message_reports - a
filestable with atypediscriminator (local vs object storage) +SoftDeletes
Phase 2 — models
-
SerializesDatestrait (Y-m-d H:i:son every timestamp) -
Conversationwithbooted()→generateReference(), andhasParticipant() -
Message,MessageAttachment,MessageReport(+ status constants) -
FilewithTYPE_*constants,isR2()/isLocal(), and the local-diskdeletinghook — plus a decision on whether object-storage cleanup belongs in the hook or stays explicit (§5)
Phase 3 — auth & guards
- Token auth (Sanctum or equivalent) on the whole chat group
- A ban/status middleware equivalent to
not_banned - Role constants +
isStudent()/isInstructor()/isAdmin() - Extract an admin middleware instead of 6 inline
isAdmin()checks (§16 #8)
Phase 4 — participant endpoints
-
index(role branch,?archived, eager-load avatars) — paginate it this time -
store(firstOrCreate, role-dependent validated field, + counterpart role check + self-thread guard) -
messages(keysetbefore,limit 50, desc fetch → asc present) -
sendMessage(one transaction: message + attachments + preview + recipient unread; broadcast after commit; + attachment ownership check +required_withoutguard) -
markRead(zero own counter + bulkUPDATE … WHERE read_at IS NULL) -
archive(toggle — or make it an explicit setter) -
report(reason constant, derivedauthor_id)
Phase 5 — resources
- Disable resource wrapping (
JsonResource::withoutWrapping()) or keep wrapping consistently — just don’t end up with a mix like §6 documents - Caller-aware
ConversationResource(collapse per-side columns tounread/archived/counterpart) -
MessageResourcewithwhenLoaded('attachments') -
MessageReportResource
Phase 6 — real-time
-
MessageSent implements ShouldBroadcast,PrivateChannel("conversation.{id}"),broadcastAs(), self-sufficientbroadcastWith() - Channel auth callback reusing
hasParticipant() - Decide the transport up front: Reverb + Echo, or an explicit polling interval. “Refetch on focus” (§11) is the current fallback and it means unbounded delivery latency
Phase 7 — object storage
- S3-compatible disk (R2/S3/MinIO),
region=auto, path-style for R2 - Upload endpoint with: participant check on the folder, type guard, server-side size cap, positive MIME allow-list, streaming
putFileAs - Key layout
files/{conversation-reference}/{userId}_{time}_{safeName} - URL-returning retrieval endpoint (skip base64 unless a client truly needs it)
- Bucket CORS policy in the provider dashboard — not in the app (§12.6)
Phase 8 — admin surface
-
adminIndex(paginate, filters,adminParticipant()contact card) -
adminShow— paginate it (§9.2) - Platform-wide export:
withCount+chunk(200)+disconnectWorksheets() - Per-thread export + cloud backup sharing one
buildConversationSpreadsheet()builder - Deterministic backup key (one archive per thread, overwrite on re-save)
- The wipe: bytes first →
forceDelete()files → rows in a transaction → reset summary fields → keep the conversation row. Batch the object deletes, queue it, and log who did it - Moderation queue with the shared
setStatus()helper
Phase 9 — hardening & verification
- Work through the 10 items in §16
- Tests for the wipe, the participant
403s, and thebefore=cursor - Re-run the §15 benchmark on the real DB and compare query counts (they should match: 3/3/9/2–3/4/5)