Community

Our Plugin Was Rejected Twice by WordPress.org. Here Is Exactly Why

The WordPress.org plugin directory review process

Our form-entry management plugin, EntryVault, was rejected twice by the WordPress.org plugin review team before it went live. Both times the reviewers were right, and both times the problem was something our own checks had never been designed to catch. This is the write-up we wish someone else had published before we submitted: what was flagged, what we changed, and the two build-time gates we now run so it does not happen again.

There is no product pitch in this post. The only useful thing we have here is the mistakes.

The timeline

  • Early July 2026: submitted to the wordpress.org directory.
  • 7 July 2026: pended (rejected pending changes). Three issues: ownership, trialware, and one security nit.
  • Fixed, resubmitted.
  • 9 July 2026: pended again. Different issues, deeper ones.
  • Fixed again.
  • 15 July 2026: approved.

Roughly eight days from the first rejection to approval. In hindsight that is fast. It did not feel fast, largely because of a procedural detail we did not know going in: only one plugin per account can sit in the review queue at a time. A rejection stalls everything else you were planning to submit behind it.

Round 1, issue A: we could not prove we were us

Our wordpress.org account was registered to a gmail.com address, while the plugin’s branding and site pointed at a domain we own. From the reviewer’s side there was nothing connecting the two. Anyone can put anyone’s brand in a readme.

Worth being precise: this was not about the plugin’s name. Nobody said the name was taken, or too generic, or trademarked. The question was purely whether the account submitting the plugin belonged to the brand the plugin claimed to be from.

The fix was two-part. We added a DNS TXT verification record at the root of our domain, and we pointed out that another plugin was already live in the directory under the same account with the same branding. Between the two, the link was established.

If you ship under a brand, sort this out before you submit. A DNS record takes ten minutes. Discovering you need one after your submission is pended costs you a round trip.

Round 1, issue B: our free build was trialware

This is the one that stung, because we thought we had built it correctly.

Our free zip still contained the licensing class and the premium feature gates, wrapped around code that was physically present in the package. In our heads this was tidy: one codebase, one gate, free users simply do not see the extra features.

That is exactly the thing the guidelines prohibit (this maps to the rule commonly cited as guideline 5). The violation is not charging money. The violation is shipping the code and then locking it.

The fix was structural and not fun: we moved the licensing class and every gated feature out of the free tree entirely. Not disabled. Not stubbed. Gone. The free build does not contain them, so there is nothing to unlock.

If you have a free tier and a paid tier, the mental model that passes review is two separate trees, with the free one complete in itself. Anything else and you do this migration under time pressure, like we did.

Round 1, issue C: a permission callback that was too generous

The smallest of the three. One of our REST API routes used a broad, generic capability in its permission callback for data that deserved a narrower check. More permissive than the data warranted, and the reviewer was correct to call it.

Fixed with a stricter, purpose-specific permission check. Ten minutes of work. The lesson is that permission callbacks get read carefully by humans, and “an admin-ish capability” is not the same as “the right capability for this data”.

Round 2: the code that ships but can never run

We fixed all three, resubmitted, and got pended again two days later. This round exposed a category of problem we had no way of seeing.

Round 1 was about explicit gates: code you can see being locked. Round 2 was about shipped but unreachable code. Our free tree contained classes whose only callers lived in the premium build, and the premium build is exactly what gets stripped out when we assemble the free zip. From the free plugin’s point of view, those classes could never execute. Functionally that is the same problem as trialware: locked functionality present in the package.

The reviewer named specific classes. Our own follow-up audit found more of the same pattern: methods with no remaining callers, and free-tier code that created database tables only the premium build ever read or wrote.

That last one survives a code review for years, because it does not error. It just quietly builds furniture for a room nobody enters.

Round 2: two letters is not a namespace

The second round-two issue was prefixes. We were using a two-letter prefix on constants, options, transients, nonce and field names, and all ten of our database tables.

Two letters is not a unique-enough namespace in a shared WordPress install. A site can be running dozens of plugins, and two-character prefixes collide. (If you have ever wondered why a busy site accumulates weird conflicts, this is part of the answer, and one reason how many plugins a site can safely run is a harder question than it looks.)

Renaming constants and options is tedious but mechanical. Renaming database tables is not. Existing installs already had ten tables under the old names with real user data in them. Change the create-tables code and walk away, and the next activation happily creates ten empty tables while the user’s entries sit stranded in the old ones, still on disk, invisible to the plugin.

So we wrote a one-time migration that renames the existing tables, running before the create-tables step. Order matters. If create-tables goes first it finds nothing, makes empty tables, and your migration then has two sets to reconcile.

The structural fix: a build gate that understands reachability

Here is the part worth stealing.

We already had a build-time check on the free zip: a forbidden-strings blacklist that fails the build if the staged free tree contains any premium class name, licence constant, and so on. It would have caught nothing in round 2, because a blacklist cannot detect unreachable code. Unreachable code contains no forbidden strings. It looks completely normal. It is just orphaned.

So we wrote a reachability gate instead. In plain terms: after staging the free tree, for every class in it, search the rest of the staged tree for a reference to that class. Zero references anywhere else in the staged output means nothing in the shipped plugin can reach it, so the build fails.

It caught a dead class on its first run. One we did not know we had.

Then we found our own second blind spot

The reachability gate was PHP-only, because our build tooling was PHP-shaped and we did not think past it. PHP-only checks cannot see JavaScript or CSS.

Our free build was shipping a premium feature’s renderer inside an admin JS file. With comments that said, in so many words, that the feature was premium-only. Nothing in our tooling looked at that file, so nothing complained.

We added a separate scan over the staged JS and CSS assets. Whatever gate you write, write the asset half too. Bundled front-end code is the easiest place for gated functionality to hide, precisely because nobody is looking there.

Plugin Check passing is necessary, not sufficient

One more thing, and it is the most useful sentence in this post if you are about to submit: the official Plugin Check tool reported zero errors and zero warnings on the exact zip that got rejected.

That is not a criticism of the tool. Plugin Check finds coding-standards problems, deprecated calls, forbidden functions, readme issues. It cannot see architecture. It has no way to know that a class is unreachable in the shipped configuration, or that your feature gates are trialware.

Run it. Get it clean. Then assume you still have problems it structurally cannot detect.

What we would tell ourselves in June

  1. Verify domain ownership before submitting, not after.
  2. Build the free tier as a complete plugin that has never heard of the paid tier. Gates in shipped code are the failure mode.
  3. Pick a real prefix. Four to six characters, distinctive, and use it on tables too, because tables are the expensive ones to rename later.
  4. Write the reachability check before you need it. It takes an afternoon and it catches a class of problem no linter will.
  5. Remember your JS and CSS.
  6. Budget for a rejection blocking your other submissions, since the queue holds one per account.
  7. Read the reviewer’s message twice before replying. Ours were specific and correct every single time.

That last point deserves emphasis, because plugin-review posts often turn into complaints. This is not one. Both rejections were our fault. We had shipped locked code, dead code, and a namespace too small to be safe, and we got told so, accurately and politely, twice.

Getting approved is only the start anyway. A listing that exists is not a listing anyone finds: how the WordPress.org plugin search actually ranks results is the companion piece to this one, and the thing we went and read properly only after we were live.

Frequently asked questions

Does a plugin rejection mean I have to start the review process over from the beginning?

No. A pended plugin stays in the queue and you reply to the same review thread with a fixed zip, which is why our two rejections and the approval all happened within about eight days. The bigger cost is not the restart, it is that only one plugin per account can be under review at a time, so anything else you were planning to submit waits until the current one clears.

If the Plugin Check tool passes, will my plugin be approved?

Not necessarily. The exact zip that got us rejected reported zero errors and zero warnings in Plugin Check. The tool is very good at coding standards, deprecated functions and readme problems, but it cannot see architectural issues like shipping locked premium features, or including classes that nothing in the free build can ever call. Treat a clean Plugin Check run as the floor, not the finish line.

Why is unreachable code a problem if it never runs?

Because it is still in the package the user downloads, and in practice it is almost always gated functionality wearing a disguise. In our case the free tree contained classes whose only callers lived in the premium build that gets stripped out of the free zip, so that code could never execute for a free user. A forbidden-strings blacklist will not find this, since orphaned code contains no forbidden strings. We ended up writing a build step that fails if any class in the staged free tree has zero references anywhere else in that tree.