← All posts

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. They also lead to 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.

Jetonomy keeps post and reply data in dedicated custom tables: jt_posts and jt_replies, with columns that match what forum queries actually need. Posts carry vote_score, reply_count, is_sticky and last_reply_at; replies carry parent_id for nesting and is_accepted for the answer the asker chose. The list views sort on indexed columns, such as a space’s topics by sticky flag and last reply, or by vote score. 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: publish, pending, spam or trash, and posts can also be a draft. The status transitions have rules. A new post goes to pending if the space requires approval. Flags are tracked separately from status: any member can flag a post, the flag lands on the moderators’ Flags tab, and a moderator decides whether it is valid (the post moves to the trash) or dismissed.

Getting this wrong is expensive. If flagging can hide content on its own, every contested post disappears and the community manager drowns in appeals. If flags go nowhere, spam and low-quality posts stay visible for too long. Keeping a human on the decision, and notifying moderators when a new flag arrives, is the balance we chose.

The admin moderation screen is the operational surface for this: a PHP admin page with tabs for held posts, held replies, flags and banned members, and inline Approve, Spam and Trash actions on each held item.

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 only works for JavaScript calls when the request carries a REST nonce. Every logged-in call from Jetonomy’s frontend sends one, so the same endpoints serve the admin, the frontend and outside integrations.

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 has to match the sort. List responses carry a cursor_next value, and it is the next offset, not a row id. An id cursor only works when the query filters on id in the same direction it sorts, and our lists sort by sticky flag, vote score, last reply or a computed “hot” ranking. Getting this wrong is easy to miss: an id cursor against a descending sort hands back the top of the list as “page 2” and a client can loop forever.

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 splits the work by size. Small spaces and threads fan notifications out inline, so subscribers hear about a reply straight away. Large ones defer the fan-out to a background WP-Cron event, so the author’s request returns fast and the subscriber loop runs on the next tick instead of blocking them.

We chose a WP-Cron single event for that hand-off because it persists to the cron option in every context, WP-CLI included, and on an active community it fires within moments. A delayed notification is acceptable; a silently dropped one is not.

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. An ALTER on a big jt_posts table can outlive the PHP time limit of whatever request happens to trigger it, and if the version is only stamped at the very end, every following request re-enters the whole batch.

Jetonomy keeps each schema change in its own versioned migration class under includes/db/migrations. At upgrade, the migrator runs only the migrations newer than the stored database version and stamps the version after each one completes, so an interrupted upgrade resumes where it stopped. It also lifts the PHP time limit for the migration pass where the host allows it, so a long ALTER is not cut off halfway.

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 scripted multi-user scenarios that run from WP-CLI, such as a thread where several members vote at once or a post that piles up flags before a moderator reaches it. They walk the same paths real members do, end to end, and report what broke.

These catch things that never surface in a clean install, like the pagination loop described above, where “page 2” quietly returned page 1.

The failure patterns we catch in scenario 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 model with flags kept separate, a REST API designed for three consumers, notification fan-out that moves to the background when a thread gets big, and a migration system that survives an interrupted upgrade.

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

LayerNon-negotiable
Post storageCustom tables, not CPTs - separate columns for counters and status
VotingA stored score column, indexed for sorting
Moderation statesOne status per row, with flags tracked separately for a human to resolve
NotificationsInline fan-out for small threads, a background event for large ones
REST APIConsistent auth, embedded aggregates in list responses, pagination that matches the sort
Schema migrationsVersioned migrations that run once each and record progress as they go
Test coverageScripted multi-user scenarios, not just clean-install tests

Every one of these is boring to build and expensive to retrofit after the fact.

What customers say on Trustpilot

WBCOM Designs is Amazing!

WBCOM Designs has constantly updated and supported their plugins and themes. Recently the new BuddyNext project is Amazing! - and the new line of addons like Mediaverse, Jetonomy, Mediashield, and Listora.. are likewise Amazing! - and very well built. Today their online Chat solved a download…

Edward S.US