What It Takes to Build a Production WordPress Forum Plugin
Behind the scenes of Jetonomy's build - the unglamorous parts of forum plugin development: data modeling, moderation queues, REST API design, notification fan-out, and upgrade paths.
When we started building Jetonomy, the public pitch was easy to write: a modern forum plugin for WordPress communities, with trust levels and rich moderation. The hard part was every decision that made the pitch possible to keep.
This is the build story from the inside - not the features, but the engineering work those features are sitting on.
The data model decision that everything else depends on
The first real decision in any forum plugin is how posts and replies are stored. There are three broad options: use WordPress custom post types, use a hybrid of CPTs and custom tables, or go fully custom tables.
CPTs are the path of least resistance. WordPress gives you a storage layer, query API, and REST routes for free. We started there and ran into the wall that every serious forum plugin eventually hits: wp_posts and wp_postmeta are not shaped for forum data at scale.
A thread with 200 replies is 201 rows in wp_posts. Query the thread to count replies, sort by votes, and find accepted answers and you’re doing multi-JOIN queries on a table designed for blog posts, not discussion. The vote count alone - which every list view shows - requires either a meta lookup per row or a denormalized counter that has to stay consistent under concurrent voting.
We moved the post and reply data to dedicated custom tables early. jetonomy_posts and jetonomy_replies with columns that match what forum queries actually need: parent_id, vote_count, reply_count, is_accepted, is_pinned, last_activity_at. Queries against these tables are direct - no JOINs to meta, no secondary lookups.
The tradeoff: we own the migration path. When we add a column, we write the ALTER TABLE. When a site upgrades, the migrator runs. This is more work up front and ongoing, but it keeps query performance predictable as communities grow.
Moderation queues
The other invisible layer that makes a forum usable at scale is the moderation queue - not as a feature users see, but as an internal state machine.
Every post and reply in Jetonomy has a status: published, pending, flagged, removed, spam. The status transitions have rules. A new post from a trust-0 member can go to pending if the space requires it. A trust-4 member flagging a post moves it to flagged but not immediately to pending - the flag-weight system calculates whether the threshold for queue-entry is met.
Getting this wrong is expensive. If flagging is too aggressive, every contested post ends up in the queue and the community manager drowns in false positives. If it’s too passive, spam and low-quality posts stay visible for too long. The threshold needs to be tunable per community without requiring code changes.
The admin queue view is the operational surface for this: a filterable list of posts in non-published states, with inline approve/reject/spam actions. We built it as a React component against the Jetonomy REST API - not a server-rendered page - because the queue needs to update in real time as members take action.
REST API design
We made a decision early to build Jetonomy as a REST-first plugin. Every meaningful operation - creating a post, voting, accepting an answer, flagging, moderating - goes through a REST endpoint. The WordPress admin UI, the frontend views, and any third-party integration all use the same API.
This sounds obvious. In practice, it means the API surface has to be designed for all three consumers simultaneously, which is harder than designing for one.
The constraints that shaped our approach:
Authentication needs to work from the frontend. WordPress’s cookie-based auth works fine for admin-side calls but is unreliable for JavaScript calls from the frontend. We added nonce-based auth for all REST endpoints and handle the nonce rotation that vanilla WP-REST doesn’t do well out of the box.
Responses have to be usable without a secondary fetch. A post list endpoint that returns only IDs is fast to build and useless for a frontend that needs to render title, author, vote count, and reply count. We embed the author object and the vote/reply aggregates in every list response. One request, usable data.
Pagination needs to be cursor-based for live feeds. Offset pagination breaks on live data - a new post inserted at the top shifts everything down, and page 2 returns a duplicate. The activity feed endpoints use cursor pagination keyed on last_activity_at plus id. It’s more complex to implement and simpler for the frontend to consume correctly.
Error responses need to be consistent. An auth failure looks different from a permissions failure from a validation failure from an upstream error. We settled on a consistent error schema - code, message, data - that the frontend can handle with a single error-handling layer.
Notification fan-out
Notifications are where forum plugins most commonly develop invisible performance problems.
A thread with 100 followers and a new reply means 100 notification records need to be written. In most naive implementations, this happens synchronously during the reply-save request. The user who posted the reply waits while 100 INSERT statements run. On a busy thread with 500 followers, this is catastrophic.
Jetonomy queues notifications. The reply saves. The queue entry is created. Control returns to the user immediately. The background worker picks up the notification job and fans out the records asynchronously.
This introduces a problem: background jobs need to be reliable. We use Action Scheduler for all async work - the same library that WooCommerce uses for its background processing. Jobs retry on failure with exponential backoff. Failed jobs surface in a dashboard. The queue doesn’t get stuck silently.
The second problem with notifications: deduplication. If a member is mentioned in a reply AND follows the thread, they should get one notification, not two. The deduplication pass runs before the fan-out, merging notifications that have the same recipient, source post, and notification type within a time window.
Upgrade paths
The ugliest part of maintaining any plugin with custom tables is schema evolution.
The naive approach is a version-number check in the activation hook: if the stored schema version is less than current, run a batch of ALTER TABLE statements. This works fine until a table is large. A forum with 500,000 posts cannot run ALTER TABLE jetonomy_posts ADD COLUMN ... inline - MySQL locks the table and the site goes down for the duration.
We use a pattern borrowed from database migration libraries: each schema change is an idempotent migration function keyed by a migration ID. At upgrade, the migrator runs only unapplied migrations. Large table changes use gh-ost patterns where possible - create a shadow table, copy rows in the background, atomic rename when done - or flag the migration as “must be run via WP-CLI on large installs” with a warning in the admin.
This is not glamorous work. It’s also the difference between a plugin that upgrades gracefully at scale and one that causes incidents on every release.
Testing against real communities
Unit tests and integration tests are necessary but not sufficient for a forum plugin. The failure modes that actually matter are ones that only appear under real usage patterns: a thread where 50 members reply simultaneously, a moderation queue that accumulates 2,000 flagged posts before anyone processes it, a community that imports 100,000 legacy forum posts in a single bulk operation.
We test against generated datasets that mimic these extremes. The wp jetonomy seed CLI command generates communities at configurable scale - X spaces, Y members with realistic trust-level distributions, Z threads with reply counts drawn from a power-law distribution (most threads have few replies; a handful have thousands).
Running the full test suite against a 10,000-post seeded community catches things that never surface in a clean install. Pagination that drifts by one on page boundaries. Vote counters that go negative under concurrent updates. Notification deduplication that misses a case when the same user replies twice in quick succession.
The failure patterns we catch in seeded testing are the ones that would otherwise become support tickets from communities that trust the plugin to run their daily workflow.
What this amounts to
The features on the demo page are real - trust levels, rich moderation, nested replies, voting, notifications. The invisible layer under them is custom tables, a status state machine, a REST API designed for three consumers, background fan-out with a reliable queue, and a migration system that doesn’t cause incidents at scale.
None of that is visible to a community member posting a question. That’s the point.
Building forum software that holds up under real community use is the kind of problem we’ve been working on for years - it’s the same engineering discipline behind our broader custom WordPress plugin development work. Jetonomy is what it looks like when you build the full stack correctly, from data model to upgrade path.
Checklist: what a production forum data layer needs
| Layer | Non-negotiable |
|---|---|
| Post storage | Custom tables, not CPTs - separate columns for counters and status |
| Voting | Optimistic counter + background reconciliation under concurrency |
| Moderation states | Status state machine with configurable thresholds, not boolean flags |
| Notifications | Async fan-out via a reliable queue; deduplication before fan-out |
| REST API | Consistent auth, embedded aggregates in list responses, cursor pagination |
| Schema migrations | Idempotent migrations, large-table strategy for ALTER statements |
| Test coverage | Seeded datasets at realistic scale, not just clean-install tests |
Every one of these is boring to build and expensive to retrofit after the fact.