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:
- Member —
Auth::guard('members')session (Memberships feature).member_idFK + name/email snapshot. - Staff — dashboard user (
webguard, checked explicitly because a members-guard session can be the app's default guard).user_idFK; their posts carry a Staff badge and always publish immediately. - 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 nullablemember_plan_idsjson (plan-gated boards).forum_threads— the opening post lives on the thread row (body);status(approved/pending/hidden),is_pinned,is_locked, denormalizedposts_count+last_posted_at+views_count, globally-unique slug (suffixed on collision viaForumThread::uniqueSlugFor()). v2 adds nullablesolution_post_id(FKforum_posts, null-on-delete).forum_posts— replies only.parent_idself-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 offorum_thread_id/forum_post_idset;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 bymember_id(email resolved at send time), guests/staff by literalemail; uniquetokenbacks the no-login unsubscribe link. Cascade-deletes with the thread or member.forum_post_likes— one 👍 per member (or staffuser_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
CampaignMailertransport (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_emailis 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)
ForumThreadis Scout-searchable (WebProSearchable, title + opening body). It's registered in SearchService::searchableModels() and InstallFulltextCommand, both gatedFeatures::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 thepartials.jsonld@include— Livewire strips inline<script>tags, the include path survives. - Sitemap:
/forum, unrestricted boards, and their approved threads are emitted by SitemapController, gatedFeatures::enabled('forum'). Plan-gated boards never appear (the sitemap is a shared response, so it uses the session-independentpubliclyVisible()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 notin resources/css/app.css (standard feature-module split). - Disabling the feature 404s all routes (
feature:forummiddleware) 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.