Skip to main content
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.

Chat & Messaging System — Full Technical Documentation

Table of Contents

  1. Overview & Scope
  2. Technology Stack
  3. System Architecture
  4. Data Model
  5. Eloquent Models
  6. API Resources (response shapes)
  7. Complete Route Map
  8. Controller Reference — Participant Endpoints
  9. Controller Reference — Admin Endpoints
  10. Reusable & Private Helper Functions
  11. Real-Time Layer — MessageSent + Channel Authorization
  12. File Sharing — CloudFlare R2 Attachment Pipeline
  13. The Admin Role — Oversight, Exports & History Wipe
  14. Moderation & Reporting
  15. Performance — Measured Response Times
  16. Security Model
  17. Error Contract
  18. Configuration & Environment Variables
  19. Tests
  20. Porting Checklist — Duplicating This System
  21. Known Gaps & Deliberate Decisions
  22. 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:
  1. conversations.last_message + last_message_at — the thread preview, written on every send. The list endpoint never touches messages.
  2. message_attachments.url / filename / mime_type / size_bytes — copied off the File row at link time, so rendering a thread needs no join to files and no per-file URL round-trip. The file_id FK is nullOnDelete, so an attachment row survives (with its URL) if the File row 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 on last_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 — but GET /admin/conversations sorts 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 models use 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

Relations: conversation(), message(), reportedBy() (reported_by_id), author() (author_id).

App\Models\File (shared, chat-relevant parts)

⚠️ The deleting hook does not clean R2. This is the single most important gotcha in the file layer: deleting a File row whose type = 2 leaves the object in the bucket forever unless the caller deletes it. Both FilesRController@r2_delete_file and ConversationController@adminWipeMessages do so explicitly. If you port this, either replicate the explicit deletes or extend the hook.

Role constants (App\Models\Role)

Checked via $user->isStudent() / isInstructor() / isAdmin() — manual checks, no Gates or Policies anywhere in this subsystem.

6. API Resources (response shapes)

Critical envelope rule: AppServiceProvider::boot() calls JsonResource::withoutWrapping(). Single resources and non-paginated collections are therefore returned unwrapped — GET /conversations and GET /conversations/{id}/messages return plain JSON arrays, not {"data": [...]}. Only hand-built paginated payloads (adminIndex) carry a data/meta envelope, 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.ts in the frontend has it right.)

App\Http\Resources\ConversationResource

Caller-aware — it resolves counterpart / unread / archived from $request->user():
Real response (one thread, student caller — plain array, no wrapper):
Privacy detail worth copying: counterpart.default_meet_url is 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

Real response (plain array):
attachments is 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 in routes/api.php:
Route order matters. /admin/conversations.xlsx is declared before /admin/conversations/{id} on purpose. Laravel matches in declaration order; the literal .xlsx route must win, or conversations.xlsx would be swallowed as an {id}. Preserve this ordering when porting.
Full endpoint table: 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/conversations instead.
  • Not paginated — ->get() returns every thread. Fine at current scale; see §15.
  • ?archived=1 includes the caller’s archived threads. The frontend actually calls it with archived=1 always and filters client-side.

8.2 store(Request $request) — POST /conversations

Idempotent thread creation.
The validated field depends on the caller’s role — a student sends 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):
  1. exists:users,id does not check the counterpart’s role. A student can pass another student’s id and create a student↔student thread stored in the instructor_id column. Add Rule::exists('users','id')->where('role_id', Role::INSTRUCTOR) (and the mirror for the instructor branch).
  2. Nothing stops student_id === instructor_id (a self-thread). Add an explicit guard.
Both are cheap validation rules; the UNIQUE(student_id, instructor_id) index does not catch either.
Returns 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.
Why keyset and not ?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.
Behaviours to carry over:
⚠️ No ownership check on attachment_ids. Any File id 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’s files/{reference}/ prefix) to the File::find().
⚠️ No validation that at least one of content / attachment_ids is present. POST with 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

Two effects, one transaction: zero the caller’s counter, and stamp 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:
Response: {"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_id is derived, never supplied — you cannot report yourself or a third party.
  • The report targets the whole conversation; message_id is an optional pointer and is nullable|integer only (not exists: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. ModerationController does extract it (denyUnlessAdmin(), §14) — that is the better pattern to carry forward, or better still an EnsureUserIsAdmin middleware applied to an /admin route group.

9.1 adminIndex(Request $request) — GET /admin/conversations

Paginated oversight list (20/page) with both participants’ contact info.
The response is hand-built, so unlike the participant endpoints it does carry a wrapper:
The q search is a leading-wildcard LIKE across two whereHas subqueries — no index can serve it. It is a full scan of users per conversation candidate. Acceptable for an admin screen; replace with a FULLTEXT index 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.
Three details worth copying: 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 removed GET /admin/conversations.csv (adminCsv). Don’t reintroduce the CSV variant here — only the finance module keeps both formats.
Filename: 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.
The object key is deterministic — 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.
⚠️ The r2 disk is configured 'throw' => false, so $disk->put() returns false on 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' => false is deliberate for the shared disk so a failed chat upload doesn’t 500; the admin R2 health check clones the config with throw => true for 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:
Note the nested 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.
Layout: rows 1–4 title block (merged 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:
Admins see contact info both participants never see about each other — email, phone, Meet room, and the parent’s name for a minor’s managed account. This is the deliberate asymmetry of the oversight role. Null-safe, so a cascade-deleted user renders as 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):
Returns 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

Every 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:
Design choices to preserve:
  • broadcastAs() pins the client-side event name to MessageSent instead 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() (not load()) — 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 has sender/created_at where the resource has read/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 (not ShouldBroadcastNow) → 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):
Same predicate as the REST guards — no third party, not even an admin, can subscribe to a thread’s live channel. Admin oversight is REST-only, by design.

Current transport state

  • .env.example ships BROADCAST_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-js dependency; 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

  1. composer require laravel/reverb and publish its config.
  2. Set BROADCAST_CONNECTION=reverb + the REVERB_* values; run php artisan reverb:start.
  3. Run a queue worker (php artisan queue:work) — ShouldBroadcast is queued. Or switch the event to ShouldBroadcastNow to publish synchronously inside the request.
  4. Expose the broadcasting auth route. Laravel 13 does not register /broadcasting/auth automatically when only api routing is configured — add ->withBroadcasting(...) in bootstrap/app.php with ['middleware' => ['auth:sanctum']], since this API has no session cookie.
  5. Frontend: npm i laravel-echo pusher-js, configure Echo with the bearer token in authEndpoint/auth.headers, then echo.private('conversation.' + id).listen('.MessageSent', cb) — note the leading dot, which is required because broadcastAs() 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

Response 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 on r2_upload_file only. POST /upload_file (with type = 2) and POST /r2_bulk_upload reach 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 note file_get_contents($uploaded->getRealPath()) reads the whole file into PHP memory before the put() — 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 share loadConversationForExport() + 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.
Response 200:
Deletion order, and why it is that order:
⚠️ 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 the messages rows remain, leaving attachment rows pointing at deleted Files (file_id is nullOnDelete, so they survive with their denormalized url pointing 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: one DELETE HTTP call to R2 plus one SQL DELETE per 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).
Safety rails that exist (all client-side): the admin UI shows a 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.

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:
Note there is no transition validation — any status can go to any status, and 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:
Four extra 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 with DB::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)
Read these as the application’s own cost — PHP, Eloquent hydration, resource serialization — not as production wall-clock. Four things are excluded and must be added for a real-world figure: 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 message costs 9 queries — the most of any chat endpoint. Breakdown: SELECT conversation → INSERT message → UPDATE conversation → then the broadcast’s load('sender','attachments') + sender->image + the response’s load('attachments'). Roughly 4 of the 9 queries exist only to build the broadcast payload — and with BROADCAST_CONNECTION=log they are spent on a log line nobody reads. Switching to Reverb makes them useful; switching the event off would make the send noticeably leaner.
  • markRead is cheap and self-limiting: one bulk UPDATE … 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.
  • archive is 2 queries — SELECT + UPDATE.
  • Keyset pagination is flat: ?before=300 costs exactly what the first page costs (2.0 vs 2.2 ms), which is the whole point of WHERE id < ? over OFFSET. Paging to message 10 000 will be the same.
The three slow spots, and why:
  1. 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.
  2. 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 drop setAutoSize.
  3. 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 R2 PUT of the whole workbook, all synchronous. Budget the export time plus R2 upload latency.
Where the wipe’s real cost hides. 1.6 ms for 500 messages looks free — because that thread had no attachments. With attachments the loop in §13.3 does one R2 DELETE HTTP request and one SQL DELETE per file:
A 200-attachment thread is therefore a 10–40 second synchronous request — well past PHP’s default 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):
A banned user’s existing token keeps working at the Sanctum layer — this middleware is what actually locks them out of chat. Any port must apply it to the chat group, not just to login.

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).
  • store cannot be spoofed — the caller’s own side is taken from $me->id, never from the body.
  • report cannot be aimed — author_id is derived from the thread, not supplied.
  • Upload folder isolation — r2_upload_file verifies hasParticipant() || isAdmin() before using a conversation’s reference as 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_url is 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: the 404 message language differs between export and non-export endpoints; 422 bodies sometimes carry error: true and 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’s authErrorMessage substring-matches them.

18. Configuration & Environment Variables

The r2 disk (config/filesystems.php):
⚠️ R2_URL must be set — r2Url() builds every stored URL as rtrim(env('R2_URL'),'/').'/'.$key. With it empty, uploads succeed and every stored url is a broken /files/... path. Also note r2_upload_file reads env() directly rather than config(): after php artisan config:cache (which the production entrypoint runs) env() returns null. Route these through config/ when porting.
⚠️ throw => false is deliberate on the shared disk so a failed chat upload doesn’t 500 — but it means put()/delete() return false instead of throwing. Check return values (§9.5).
PHP extensions required at runtime: 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):
Not covered — worth adding in a port: the send/read/archive/report happy paths, participant 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)
  • reference column, string(40), unique, nullable (populated by the model hook)
  • messages (content nullable, read_at nullable, kind string, booking json)
  • message_attachments (file_id nullable nullOnDelete, denormalized url/filename/mime_type/size_bytes)
  • message_reports
  • a files table with a type discriminator (local vs object storage) + SoftDeletes

Phase 2 — models

  • SerializesDates trait (Y-m-d H:i:s on every timestamp)
  • Conversation with booted() → generateReference(), and hasParticipant()
  • Message, MessageAttachment, MessageReport (+ status constants)
  • File with TYPE_* constants, isR2()/isLocal(), and the local-disk deleting hook — 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 (keyset before, limit 50, desc fetch → asc present)
  • sendMessage (one transaction: message + attachments + preview + recipient unread; broadcast after commit; + attachment ownership check + required_without guard)
  • markRead (zero own counter + bulk UPDATE … WHERE read_at IS NULL)
  • archive (toggle — or make it an explicit setter)
  • report (reason constant, derived author_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 to unread/archived/counterpart)
  • MessageResource with whenLoaded('attachments')
  • MessageReportResource

Phase 6 — real-time

  • MessageSent implements ShouldBroadcast, PrivateChannel("conversation.{id}"), broadcastAs(), self-sufficient broadcastWith()
  • 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 the before= cursor
  • Re-run the §15 benchmark on the real DB and compare query counts (they should match: 3/3/9/2–3/4/5)

21. Known Gaps & Deliberate Decisions

Deliberate — do not “fix” these

Gaps — carry these into the port as TODOs


22. File Inventory

Everything the chat & messaging system touches, in dependency order.

Backend — profelite_backend/

Frontend — profelite_frontend/ (consumers, for contract reference)