OForum v1.0.0
A modern community discussion forum for WordPress — rooms, threads, replies, reactions, @mentions, trust levels, shadow banning, full-text search, and a REST API. No BuddyPress, no bbPress, no jQuery.
What OForum does
A full forum system built entirely on WordPress CPTs — no external dependencies, no legacy libraries. Everything from post types to REST routes is first-party code.
[oforum], [oforum_room], [oforum_thread]Getting installed
Manual upload
oforum/ folder to /wp-content/plugins/.of_room, of_thread, of_reply), creates 6 database tables, creates a /forum page with the index template, and flushes rewrite rules.Via ZIP upload
Go to Plugins → Add New → Upload Plugin, select oforum.zip, click Install Now, then Activate.
Up and running in minutes
/forum/room-slug/./forum/your-room/ and click + New Thread./forum/. If the page was not created on activation, go to OForum → Settings and the plugin will recreate it on the next page load.Plugin architecture
Data model
| CPT | post_type | Visibility | Notes |
|---|---|---|---|
| Forum Room | of_room | Private | Settings stored in post_meta |
| Forum Thread | of_thread | Public | SEO-friendly URL: /forum/thread/[slug] |
| Forum Reply | of_reply | Private | Managed entirely via plugin, not WP admin |
Thread → room: post_meta key of_thread_room_id. Reply → thread: of_reply_thread_id. Reply hierarchy (quoting): of_reply_parent_id.
Admin panel
A standalone top-nav interface registered as a single add_menu_page() entry — no sidebar sub-items. Matches the Orravo admin UI pattern.
| Row | Contents |
|---|---|
| Brand bar | OForum icon + name + version badge + light/dark theme toggle |
| Top nav | Dashboard · Rooms · Threads | Moderation | Analytics | Settings |
The Moderation tab shows a live badge with the count of pending reports + pending approval posts. Theme preference is saved to localStorage under of_admin_theme; dark mode applies class of-dark to #of-wrap.
Rooms & structure
Rooms are of_room CPT posts. Settings are stored as post meta. Sub-rooms (one level of nesting) appear indented under their parent on the forum index.
Room properties
| Property | Meta key | Description |
|---|---|---|
| Name | post_title | Display name |
| Slug | post_name | URL slug |
| Description | post_content | Room description |
| Icon | of_room_icon | Emoji or text icon |
| Color | of_room_color | Hex colour for accent |
| Visibility | of_room_visibility | public, members, or role |
| Required role | of_room_required_role | WP role slug for role-gated rooms |
| Sub-room of | post_parent | Parent room ID (0 = top-level) |
| Sort order | menu_order | Display sort order |
PHP API
PHP// Create a room
$room_id = OF_Rooms::create([
'name' => 'General Discussion',
'icon' => '💬',
'color' => '#38BDF8',
'visibility' => 'public',
]);
// Query rooms
$rooms = OF_Rooms::get_all(); // All rooms (flat)
$rooms = OF_Rooms::get_all(false); // Top-level only
$room = OF_Rooms::get($room_id); // By ID
$room = OF_Rooms::get_by_slug('general-discussion');
// Visibility check
$can = OF_Rooms::user_can_view($room_id, $user_id);
Thread system
Threads are of_thread CPT posts at URL /forum/thread/[slug]/. Thread type controls the display badge and behavior.
Thread meta keys
| Key | Type | Description |
|---|---|---|
of_thread_room_id | int | Room the thread belongs to |
of_thread_type | string | discussion, question, announcement, showcase |
of_thread_sticky | int | 1 = pinned to top of room |
of_thread_closed | int | 1 = no new replies (except mods) |
of_thread_solved | int | 1 = has an accepted solution |
of_thread_solution_id | int | Reply ID of the accepted solution |
of_thread_reply_count | int | Cached reply count |
of_thread_views | int | View count |
of_thread_last_reply | datetime | Last reply timestamp (for sorting) |
of_thread_last_reply_uid | int | User ID of the last replier |
Querying threads
PHP$result = OF_Query::get_threads([
'room_id' => 5,
'thread_type' => 'question',
'sticky_first' => true,
'page' => 1,
'per_page' => 25,
'orderby' => 'last_reply', // last_reply | date | reply_count
]);
// Returns:
// [
// 'threads' => [...], // array of formatted thread arrays
// 'total' => 42,
// 'max_pages' => 2,
// ]
Admin thread actions (AJAX)
All admin thread actions use the of_thread_action AJAX endpoint with an action_type param:
| action_type | Effect |
|---|---|
sticky | Toggle sticky (pinned to top) |
close | Toggle closed (no new replies) |
delete | Trash the thread |
approve | Publish a pending thread |
Reply system
Replies are of_reply CPT posts (not public). All interactions go through the plugin's REST API and AJAX handlers — never through the standard WP admin.
Reply meta keys
| Key | Type | Description |
|---|---|---|
of_reply_thread_id | int | Parent thread ID |
of_reply_parent_id | int | Quoted reply ID (0 = top-level) |
of_reply_edited_at | datetime | Last edit timestamp |
of_reply_is_solution | int | 1 = accepted as solution |
of_shadow_banned | int | 1 = shadow banned (only author sees it) |
Accepted solutions
Thread authors and moderators can mark a reply as the accepted answer on Question threads. This sets of_reply_is_solution = 1 on the reply, and of_thread_solved = 1 + of_thread_solution_id on the thread. The solution reply is pinned above pagination with a green ✓ Accepted Answer badge.
Edit window
Users can edit their own replies within a configurable window (default: 30 minutes). After the window, only moderators can edit. Edited replies show (edited) next to the timestamp. Set to 0 in Settings to disable editing entirely.
Reply quoting
Clicking Quote stores the reply ID in #of-quoted-reply-id and prefills the form with a quote preview. The of_reply_parent_id is set on submission to maintain the reply hierarchy.
Emoji reactions
Stored in wp_of_reactions. Toggling the same emoji again removes the reaction. One reaction per emoji per user per reply, enforced by a unique DB constraint.
PHP API
PHP// Toggle a reaction (returns action + updated counts)
$result = OF_Reactions::toggle($user_id, $reply_id, '👍');
// ['action' => 'added'|'removed', 'counts' => ['👍' => 3, '❤️' => 1]]
// Get counts for a reply
$counts = OF_Reactions::get_counts($reply_id);
// Get emojis a user has reacted with
$user_reactions = OF_Reactions::get_user_reactions($user_id, $reply_id);
REST API
HTTPPOST /wp-json/oforum/v1/reactions
{ "reply_id": 42, "emoji": "👍" }
// Response:
{ "action": "added", "counts": { "👍": 3, "❤️": 1 } }
Configure available emojis at Settings → Reactions → Available Emojis as a comma-separated list. Default: 👍,❤️,😂,🔥,💡. Override programmatically with the of_allowed_emojis filter.
Subscriptions & notifications
Subscription types & frequencies
| Type | Notified when |
|---|---|
room | Any new thread posted in the room |
thread | Any new reply posted in the thread |
| Frequency | Behaviour |
|---|---|
immediate | Email sent immediately on each new post |
daily | Queued for daily digest (planned for v1.1.0) |
weekly | Queued for weekly digest (planned for v1.1.0) |
none | In-forum notification only, no email |
PHPOF_Notify::subscribe($user_id, 'thread', $thread_id, 'immediate');
OF_Notify::unsubscribe($user_id, 'thread', $thread_id);
$is_subbed = OF_Notify::is_subscribed($user_id, 'thread', $thread_id);
In-forum notification types
| Type | Trigger |
|---|---|
new_reply | Someone replied to a thread you follow |
mention | Someone @mentioned you |
moderation_notice | A moderator actioned one of your posts |
solution_accepted | Your reply was marked as the solution |
PHPOF_Notify::create($user_id, [
'type' => 'mention',
'post_id' => $post_id,
'from_user' => $author_id,
'message' => 'John Doe mentioned you.',
'url' => get_permalink($post_id) . '#reply-' . $reply_id,
]);
@mentions & polling
On reply submit, OF_Notify::parse_mentions() scans for @username patterns, resolves users by user_login and user_slug, and fires immediate in-forum notifications. The notification bell polls for unread counts every 60 seconds via AJAX (of_get_notifications).
Moderation tools
Permission levels
| Constant | Level | Label | Description |
|---|---|---|---|
LEVEL_BANNED | -1 | Banned | Cannot post; IP ban check runs here |
LEVEL_NEW | 0 | New Member | First posts need moderator approval |
LEVEL_MEMBER | 1 | Member | Full posting permissions |
LEVEL_MOD | 2 | Moderator | Can approve, warn, delete, shadow-ban |
LEVEL_ADMIN | 3 | Admin | WordPress admins auto-receive this level |
PHPOF_Permissions::set_level($user_id, OF_Permissions::LEVEL_MOD);
Report queue
Users click Report on any reply to file a report. Moderators see the queue at Moderation → Flagged Reports with four actions:
| Action | Effect |
|---|---|
| ✓ Approve | Publish the post |
| ⚠ Warn | Send a moderation email to the author (no post action) |
| 🗑 Delete | Trash the post |
| ✕ Dismiss | Dismiss the report without action |
Shadow ban
Shadow-banned users' future posts are only visible to themselves and moderators. Other users see nothing — the banned user has no idea they're banned.
PHPOF_Moderation::shadow_ban_user($user_id, $mod_user_id);
OF_Moderation::unshadow_ban_user($user_id, $mod_user_id);
$is_banned = OF_Moderation::is_shadow_banned($user_id);
IP ban
PHP// Permanent ban
OF_Moderation::ban_ip('192.168.1.1', $mod_id, 'Spam', null);
// Timed ban — expires 2026-12-31
OF_Moderation::ban_ip('192.168.1.1', $mod_id, 'Abuse', '2026-12-31 23:59:59');
OF_Moderation::unban_ip('192.168.1.1', $mod_id);
$is_banned = OF_Permissions::is_ip_banned('192.168.1.1');
IP bans block the post/reply REST API endpoints. The auto-moderation hook also checks IPs on reply submission.
Audit log
Every moderation action is logged to wp_of_mod_log. View at Moderation → Audit Log.
PHP$log = OF_Moderation::get_log(50); // Last 50 entries
Trust level system
Trust levels are automatically recalculated every time a user posts a thread or reply. Thresholds are configurable at Settings → Trust Level Thresholds.
| Level | Constant | Label | Default threshold |
|---|---|---|---|
| 0 | OF_Trust::LEVEL_NEW | New Member | 0 posts — first post needs approval |
| 1 | OF_Trust::LEVEL_MEMBER | Member | 5 posts |
| 2 | OF_Trust::LEVEL_REGULAR | Regular | 25 posts |
| 3 | OF_Trust::LEVEL_LEADER | Leader | 100 posts |
Trust gates
| Action | Minimum trust required |
|---|---|
| Post threads and replies | Level 1 Member |
| Post links in content | Level 1 Member |
| Edit own reply (within window) | All levels |
| First post requires approval | Level 0 New Member only |
PHP API
PHP$level = OF_Trust::get_level($user_id); // int 0–3
$label = OF_Trust::get_label($level); // "New Member", "Member", …
OF_Trust::recalculate($user_id); // Force recalculate
$needs_approval = OF_Trust::requires_approval($user_id); // bool
Full-text search
Available at every level — forum index, room, and thread pages. Results include <mark> tags around matching terms for keyword highlighting.
PHP API
PHP$result = OF_Search::search('doctrine of grace', [
'room_id' => 5,
'thread_type' => 'discussion',
'author_id' => 0,
'date_from' => '2025-01-01',
'date_to' => '2025-12-31',
'page' => 1,
'per_page' => 20,
]);
// Returns:
// [
// 'results' => [['id', 'title', 'excerpt', 'url', 'date', 'author', 'type'], ...],
// 'total' => 42,
// 'max_pages' => 3,
// ]
REST API & live search
HTTPGET /wp-json/oforum/v1/search?q=keyword&room_id=5&page=1
The forum index and room pages include a live search box (#of-search-input) that calls the REST API after a 300ms debounce and renders results in #of-search-results.
SEO integration
Breadcrumbs
PHPOF_SEO::render_breadcrumbs(); // Forum index
OF_SEO::render_breadcrumbs($room); // Room page
OF_SEO::render_breadcrumbs($room, $thread); // Thread page
Renders BreadcrumbList Schema.org markup on all forum pages.
DiscussionForumPosting schema
On thread pages, OF_SEO::inject_schema() outputs a <script type="application/ld+json"> block with DiscussionForumPosting structured data — headline, author, date, reply count, and first 5 reply comments.
Page titles & sitemap
The plugin filters document_title_parts and wp_title for proper titles: Thread Title — Site Name and Room Name — Forum — Site Name.
OForum integrates with Yoast SEO (wpseo_sitemap_index_links) and The SEO Framework (the_seo_framework_sitemap_urls) to include the 200 most recently modified published thread URLs in the sitemap.
REST API reference
Base URL: /wp-json/oforum/v1/. All write endpoints require WordPress authentication via X-WP-Nonce header or cookie-based auth.
Rooms
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /rooms | None | List all rooms |
| GET | /rooms/{id} | None | Get single room |
| POST | /rooms | Admin | Create room |
| PUT | /rooms/{id} | Admin | Update room |
| DELETE | /rooms/{id} | Admin | Delete room |
Threads
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /threads?room_id=X | None | List threads (paginated) |
| GET | /threads/{id} | None | Get single thread |
| POST | /threads | Logged in | Create thread |
| PUT | /threads/{id} | Author or Mod | Update thread |
| DELETE | /threads/{id} | Moderator | Delete thread |
JSON// Create thread body
{
"title": "My thread title",
"content": "<p>Thread content</p>",
"type": "discussion",
"room_id": 5
}
Replies
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /replies/{id} | None | Get single reply |
| POST | /replies | Logged in | Post reply |
| PUT | /replies/{id} | Author (within window) | Edit reply |
| DELETE | /replies/{id} | Author or Mod | Delete reply |
JSON// Create reply body
{
"thread_id": 42,
"parent_id": 0,
"content": "<p>My reply</p>"
}
Reactions & search
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /reactions | Logged in | Toggle emoji reaction — body: {"reply_id":42,"emoji":"👍"} |
| GET | /search?q=term | None | Full-text search — params: q, room_id, page, per_page |
Shortcodes
[oforum]
slug or id.[oforum_room slug="general-discussion"]
[oforum_room id="5"]
[oforum_thread id="42"]
Frontend templates
Templates live in the templates/ directory. They load the active theme's header.php and footer.php via get_header() / get_footer().
URL routing
| URL pattern | Template |
|---|---|
/forum/ | templates/forum-index.php — via page template |
/forum/{room-slug}/ | templates/room-page.php — via of_room rewrite var |
/forum/thread/{thread-slug}/ | templates/thread-page.php — via is_singular('of_thread') |
/forum/profile/{username}/ | templates/profile-page.php — via of_profile rewrite var |
Frontend JS globals
The OF global object is available on all forum pages:
JSwindow.OF = {
ajaxUrl: 'https://site.com/wp-admin/admin-ajax.php',
restUrl: 'https://site.com/wp-json/oforum/v1/',
nonce: '...', // WP REST nonce
ajaxNonce: '...', // AJAX nonce
loggedIn: 1, // 1 or 0
userId: 42,
loginUrl: '...',
forumUrl: '/forum/',
perPage: 25,
};
window.OF_emojis = ['👍', '❤️', '😂', '🔥', '💡']; // from settings
your-theme/oforum/ to override them. (Template override detection is planned for v1.1.0; child-theme CSS overrides are the recommended approach in v1.0.0.)Settings reference
All settings stored as WordPress options via get_option / update_option.
| Option key | Type | Default | Description |
|---|---|---|---|
of_replies_per_page | int | 25 | Replies loaded per page on thread view |
of_edit_window_minutes | int | 30 | Minutes users can edit their own replies (0 = disabled) |
of_guest_read | bool | 1 | Whether non-logged-in users can read public rooms |
of_rich_text_enabled | bool | 1 | Enable rich text toolbar in reply form |
of_markdown_enabled | bool | 0 | Enable Markdown mode toggle per user |
of_reaction_emojis | string | 👍,❤️,😂,🔥,💡 | Comma-separated allowed emojis |
of_akismet_enabled | bool | 0 | Enable Akismet spam filtering (requires Akismet plugin) |
of_trust_threshold_1 | int | 5 | Posts needed to reach Level 1 (Member) |
of_trust_threshold_2 | int | 25 | Posts needed to reach Level 2 (Regular) |
of_trust_threshold_3 | int | 100 | Posts needed to reach Level 3 (Leader) |
Filters & actions
Filters
PHP// Modify allowed reaction emojis
add_filter('of_allowed_emojis', function($emojis) {
return ['👍', '❤️', '🎉', '🤔', '👀'];
});
// Modify room visibility check
add_filter('of_room_can_view', function($can, $room_id, $user_id) {
// Custom logic — return bool
return $can;
}, 10, 3);
// Modify reply content before save
add_filter('of_reply_content', function($content, $user_id) {
// e.g. strip disallowed HTML
return $content;
}, 10, 2);
Actions
PHP// Fired after a thread is created
add_action('of_thread_created', function($thread_id, $room_id, $author_id) {
// e.g. post to Slack channel
}, 10, 3);
// Fired after a reply is created and published
add_action('of_reply_created', function($reply_id, $thread_id, $author_id) {
// e.g. award points in a gamification plugin
}, 10, 3);
// Fired after trust level changes
add_action('of_trust_level_changed', function($user_id, $new_level, $old_level) {
// e.g. award badge
}, 10, 3);
// Fired after a report is filed
add_action('of_report_filed', function($report_id, $reporter_id, $post_id) {
// e.g. notify admin via email
}, 10, 3);
save_post_of_thread and save_post_of_reply directly.Database schema
6 custom tables in addition to the standard WP tables used for the CPTs.
of_reactions
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AI PK | |
user_id | BIGINT UNSIGNED | Reacting user |
reply_id | BIGINT UNSIGNED | Reply being reacted to |
emoji | VARCHAR(10) | Emoji character |
created_at | DATETIME | When the reaction was added |
Unique constraint on (user_id, reply_id, emoji) — one reaction per emoji per user per reply.
of_subscriptions
| Column | Type | Description |
|---|---|---|
user_id | BIGINT UNSIGNED | Subscriber |
sub_type | VARCHAR(20) | thread or room |
object_id | BIGINT UNSIGNED | Thread ID or room post ID |
frequency | VARCHAR(20) | immediate, daily, weekly, none |
created_at | DATETIME |
of_notifications
| Column | Type | Description |
|---|---|---|
user_id | BIGINT UNSIGNED | Recipient |
type | VARCHAR(50) | new_reply, mention, moderation_notice, solution_accepted |
post_id | BIGINT UNSIGNED | Related post ID |
from_user | BIGINT UNSIGNED | Sender user ID |
message | TEXT | Notification text |
url | VARCHAR(500) | Click-through URL |
is_read | TINYINT(1) | 0 = unread, 1 = read |
email_sent | TINYINT(1) | 0 = pending, 1 = sent |
created_at | DATETIME |
of_reports
| Column | Type | Description |
|---|---|---|
reporter_id | BIGINT UNSIGNED | User who filed the report |
post_id | BIGINT UNSIGNED | Reported post |
post_type | VARCHAR(20) | of_thread or of_reply |
reason | VARCHAR(50) | spam, offensive, misinformation, off-topic, other |
detail | TEXT | Optional additional context |
status | VARCHAR(20) | pending, dismissed, actioned |
reviewed_by | BIGINT UNSIGNED | Moderator who reviewed |
reviewed_at | DATETIME | When reviewed |
created_at | DATETIME | When filed |
of_ip_bans & of_mod_log
| Table | Key columns |
|---|---|
of_ip_bans | ip_address (UNIQUE), reason, banned_by, expires_at (NULL = permanent) |
of_mod_log | mod_id, action (e.g. shadow_ban, ip_ban, dismiss_report), target_id, target_type, detail |
Uninstall & cleanup
When the plugin is deleted (not just deactivated), of_uninstall() permanently removes all forum data.
of_room, of_thread, of_reply posts and their post meta.of_ prefix.What's shipped
- Full forum room + thread + reply system with
of_room,of_thread,of_replyCPTs - 4-level trust system with auto-recalculation on every post
- Emoji reactions — configurable set, toggle-to-remove, per-reply counts
- Full-text search with filters (room, type, author, date range) + keyword highlights
- Thread and room subscriptions + immediate email notifications
- In-forum notification bell with unread count badge + dropdown
- @mention detection with immediate in-forum notification
- Reply quoting — prefills form with quoted text + sets parent ID
- Thread reading position memory (localStorage)
- Solved / accepted solution marking — pinned above pagination
- Moderation queue: report, dismiss, warn, delete
- Shadow ban + IP ban (with optional expiry) + moderation audit log
- Standalone admin panel with top nav (Orravo UI pattern)
- Dark / light theme toggle in admin (localStorage persistence)
- REST API: full CRUD for rooms, threads, replies, reactions, search
- SEO: BreadcrumbList schema, DiscussionForumPosting schema, Yoast + SEO Framework sitemap integration
- Analytics dashboard: posts/day, peak hours, top users, room stats
- Shortcodes:
[oforum],[oforum_room],[oforum_thread] - Clean uninstall hook — drops tables, CPT posts, and options
- No jQuery dependency on frontend — vanilla JS throughout
- PHP 8.0+ required, WordPress 6.0+ required
- Multisite compatible
Got a question about OForum?
Reach out directly — Kenneth replies within 24 hours.
