Skip to main content

Documentation

No results found.
Features

Forum

Community discussion boards with one-level threaded replies, member/staff/guest posting, and a moderation queue with visitor reports. Opt-in feature (forum, default off), toggled at Dashboard → Settings → Features. Module lives at app/Featu...

Community discussion boards with one-level threaded replies, member/staff/guest posting, and a moderation queue with visitor reports. Opt-in feature (forum, default off), toggled at Dashboard → Settings → Features. Module lives at app/Features/Forum/.

Public pages

Three Livewire pages under /forum, registered by the module's routes/web.php (web + feature:forum, never response-cached — reply forms prefill from the member session and view counters are per-visitor):

URL View What it shows
/forum forum::public.index Board list (name, description, thread count, last activity) + recent-activity rail
/forum/{categorySlug} forum::public.category Thread list (pinned first, then by last activity) + "Start a discussion" form
/forum/{categorySlug}/{threadSlug} forum::public.thread Opening post, threaded replies, reply form, report flow

Route params are deliberately named categorySlug/threadSlug — Livewire assigns any route param onto a same-named public property, which would collide with the components' typed $category/$thread model properties.

A fourth route, /forum/unsubscribe/{token} (forum::public.unsubscribe), is the tokenized no-login landing for the unsubscribe link in reply-notification emails. It's registered before the {categorySlug}/{threadSlug} pair so the literal unsubscribe segment can't be captured as a category slug.

Reading is fully public (except plan-gated boards — see below). Thread views count once per session (forum_thread_viewed_{id} session key), incremented via the query builder so updated_at is untouched.

Who can post (ForumPoster)

ForumPoster resolves posting identity in precedence order:

  1. Member — Auth::guard('members') session (Memberships feature). member_id FK + name/email snapshot.
  2. Staff — dashboard user (web guard, checked explicitly because a members-guard session can be the app's default guard). user_id FK; their posts carry a Staff badge and always publish immediately.
  3. Guest — only when Allow guest posting is on; requires name + email (email never shown publicly). Guest submissions are always held for moderation.

Initial status: staff → approved; guests → pending; members → pending when Hold every post for approval is on, else approved.

Trust levels (v2). With Auto-approve regulars after set to N > 0, a member whose previously-approved contributions (threads + replies) have reached N skips the require-approval hold (ForumPoster::currentMemberIsTrusted()). Guests never qualify, and the setting is inert when Hold every post for approval is off (nothing is held anyway).

Without Memberships enabled and with guests off, the forum is effectively read-only for visitors (the "Sign in to post" prompt only renders when memberships is enabled).

Spam posture mirrors the product-review form: hidden honeypot field on every public form (bots get a fake success), per-IP rate limits (3 posts/min, 30/day; 5 reports/day) via RateLimiter.

Data model

Four v1 tables (migration 2026_07_04_900000_create_forum_tables.php):

  • forum_categories — boards: name, unique slug, description, position. v2 adds nullable member_plan_ids json (plan-gated boards).
  • forum_threads — the opening post lives on the thread row (body); status (approved/pending/hidden), is_pinned, is_locked, denormalized posts_count + last_posted_at + views_count, globally-unique slug (suffixed on collision via ForumThread::uniqueSlugFor()). v2 adds nullable solution_post_id (FK forum_posts, null-on-delete).
  • forum_posts — replies only. parent_id self-FK gives one-level threading: replying to a nested reply attaches to its top-level parent, so nesting never deepens. Same status set as threads.
  • forum_reports — visitor flags; exactly one of forum_thread_id/forum_post_id set; open/resolved/dismissed. Reports cascade-delete with their target.

Two v2 tables (migration 2026_07_05_900000_add_forum_v2_tables.php):

  • forum_subscriptions — "notify me of replies" per thread. Members subscribe by member_id (email resolved at send time), guests/staff by literal email; unique token backs the no-login unsubscribe link. Cascade-deletes with the thread or member.
  • forum_post_likes — one 👍 per member (or staff user_id) per reply; guests can't like.

member_id/user_id are nullable FKs (members, users) with name/email snapshots so authorship survives account deletion. ForumThread::refreshStats() recomputes posts_count (approved replies) and last_posted_at after any reply create/approve/hide/delete.

Reply notifications & subscriptions (v2)

  • Starting a thread auto-subscribes the author (member id, or email for guests/staff). Repliers opt in with the Notify me of replies checkbox.
  • ForumEmails sends through the shared CampaignMailer transport (same inline-table HTML shape as the ticket emails) with a forum-scoped sender override (forum.email.from_email/from_name, empty = marketing defaults). Sends are best-effort — a mail failure is reported, never blocks the write.
  • Subscriber notifications fire only when a reply becomes approved: on creation when it publishes immediately, or when a moderator approves it from the queue. Pending/hidden replies never notify. The reply's own author is always skipped, and every email carries that subscription's tokenized unsubscribe link (also passed as the transport's List-Unsubscribe URL).
  • Moderation alerts: when forum.notify_email is set, staff get an email whenever a thread or reply lands in the moderation queue.

Reactions & solutions (v2)

  • Members and staff can 👍 a reply (toggleLike); like counts render on every reply. Guests see counts only.
  • The thread author (member match) or staff can mark one reply as the solution (forum_threads.solution_post_id). The solution renders as a highlighted card directly after the opening post (with a "View in context" jump link) plus a Solution badge in the reply list; solved threads carry a Solved badge on the thread page and in listings.

Search & SEO (v2)

  • ForumThread is Scout-searchable (WebProSearchable, title + opening body). It's registered in SearchService::searchableModels() and InstallFulltextCommand, both gated Features::enabled('forum') where applicable, so all three search modes (LIKE / MySQL FULLTEXT / Meilisearch) work.
  • Dashboard cmd-K palette (GlobalSearch): a "Forum" group (every status — staff can jump to pending threads) linking to the public thread page.
  • Public site search (PublicSearch): a "Forum" group of approved threads, filtered to boards the current visitor may see (the search page is never response-cached, so the per-visitor filter can't leak).
  • JSON-LD: the thread page emits DiscussionForumPosting (headline, text, author, date, comment list, interaction count) through the partials.jsonld @include — Livewire strips inline <script> tags, the include path survives.
  • Sitemap: /forum, unrestricted boards, and their approved threads are emitted by SitemapController, gated Features::enabled('forum'). Plan-gated boards never appear (the sitemap is a shared response, so it uses the session-independent publiclyVisible() scope).

Similar-thread deflection (v2)

As a visitor types a new discussion title, the form suggests up to 3 matching approved threads (and up to 2 Documentation articles when that feature is on) — word-based matching ranked with title hits outranking body-only hits, mirroring the ticket form's knowledge-base deflection. Suggestions respect board plan-gating.

Plan-gated (private) boards (v2)

Boards can require a member plan (Boards modal → Restrict to member plans, shown only when Memberships is enabled). Entitlement uses MemberContentAccess::responseForPlan() — same tier rule as page/content gating (any checked plan, or a higher tier in the same plan group; per-record gate, so it works regardless of the install-level content-gating toggle). Non-entitled visitors never see the board: index, recent activity, search, deflection, and sitemap all filter it, and direct board/thread URLs redirect anonymous visitors to member login (intended URL preserved) or members to the upgrade page. Staff always see everything; if every required plan is deleted the board fails open; with Memberships disabled the restriction is inert.

Dashboard

Four pages (routes/cms.php), sidebar group Forum (gated on the feature flag):

Page Route Role What it does
Discussions dashboard.forum.index manager All threads: status tabs (All/Pending/Locked/Hidden), search, board filter; per-thread approve / pin / lock / hide / delete (Flux confirm modal)
Boards dashboard.forum.categories manager Board CRUD in modals; optional member-plan restriction (v2, Memberships only) with a Members only badge in the table; deleting a board warns with the live thread count (threads cascade)
Moderation dashboard.forum.moderation manager Two tabs: Pending approval (held threads + replies, approve/delete) and Reports (dismiss / hide content / delete content). Approving a reply notifies the thread's subscribers. Hiding or deleting resolves/clears every open report against the same target
Settings dashboard.forum.settings admin Forum title + intro text, Allow guest posting, Hold every post for approval, Auto-approve regulars after (v2), moderation-alert address + sender overrides (v2), discussions/replies per page

The sidebar Moderation item and the dashboard Forum widget (ForumServiceProvider) both surface the pending count (pending threads + pending posts + open reports).

Settings keys

All under forum.*, typed accessors in ForumSettings: title, intro_text, allow_guests (default false), require_approval (default false), auto_approve_after (default 0 = off), notify_email (default empty = off), email.from_email / email.from_name (default empty = marketing sender), threads_per_page / posts_per_page (default 20).

Conventions & gotchas

  • Bodies are plain text, rendered with {{ }} + whitespace-pre-line — no rich text, no XSS surface.
  • Public pages use their own lightweight Older/Newer pagers (#[Url(as: 'page')]), not the vendor paginator (its Blade views aren't in the public CSS bundle).
  • Public views are excluded from the dashboard CSS bundle via @source not in resources/css/app.css (standard feature-module split).
  • Disabling the feature 404s all routes (feature:forum middleware) and hides the sidebar group; data is preserved.

Tests

tests/Feature/ForumPublicTest.php (visibility, posting identities, approval flows, honeypot, locked threads, threading, reports, view counting; v2: subscriptions + notification paths, unsubscribe, trust levels, likes, solutions, JSON-LD, sitemap gating, deflection, plan-gated boards) and tests/Feature/ForumDashboardTest.php (auth/role gates, tabs/search, thread actions, board CRUD, moderation queue, report resolution, settings; v2: plan restrictions, v2 settings).

Future ideas

  • Member profiles/avatars on posts; edit-own-post windows.
  • Digest emails (daily/weekly activity summaries instead of per-reply sends).
  • Meilisearch filterable attributes for board-level filtering at the engine layer.