What the WordPress.org plugin review actually flagged in my plugin

By Monowar Hossain

Summary. I submitted a plugin to WordPress.org in late July 2026. It came back twice: once over the name, once over the code. Approval came on 20 August, about three and a half weeks after I first hit submit. Neither round was unfair. Both times the problem had been sitting in my repository for months, and both times I had configured my own linter to keep quiet about it. So: what they flagged, the code before and after, the mistake I managed to make twice, and what actually happens once the approval email lands.

The first email may not be from a human

This is the part I wish I had known before I opened it.

Two days after submitting, an email arrived. Buried in the header was this:

πŸ€– Please note that this message was generated using a combination of humans, algorithms, and AI in varying proportions. It may not have been reviewed by a human. All AI outputs are marked with the ✨ emoji.

It called itself an automated pre-review. Three weeks later a second email came, and that one opened differently: "the volunteers have manually checked it."

So there are really two gates. A machine screens for the obvious mechanical problems first. Only once you are through that does a person open your code.

That changes how you should read the first email. It is a checklist, not a verdict. Clear it fast, do not argue with it, get to the human.

Round 1: the name

I had called it Unused Image Cleaner. The ✨-marked verdict:

The display name is too generic and closely matches existing plugin names built around the exact phrase "Unused Image Cleaner," which makes it confusing in the directory.

Fair enough, and I should have checked first. Search the directory for that phrase and you get a wall of plugins doing roughly the same job.

Finding a replacement was harder than I expected, because of how they define "similar":

We also look into similar naming patterns, lookalike names or meanings.

Meanings, not spellings. My first idea was ImageJanitor Guard. Dead on arrival: the directory already has Upload Janitor, described as "Clean up unused images and other files from your uploads folder." Different words, same idea. Two more candidates went the same way. Both already existed.

The rule that finally helped was this one:

❌ Changing the plugin name by adding an additional letter or a generic word (Advanced, Simple, etc) will probably not solve this problem at all.

βœ… You could try adding a coined term, your personal brand or a unique identifier at the beginning of the name.

At the beginning, not the end. So I made up a word and put it first.

Two more things about names that are easy to miss. They check your username, your plugin URLs, your icon and banner, not only the title. And the slug is a separate request. Changing it in your code does nothing; you have to state the permalink you want in your reply, and after approval it can never be changed again.

The real lesson: search the directory before you write a line of code. Rename afterwards and your slug, prefix, constants, hooks, database tables and repository URL all move at once.

Round 1: one script tag, seven problems

They quoted exactly one line, a <script> tag in an admin page.

One line. But the email also says this, and it is the sentence everybody skims:

Then search your codebase for other occurrences of the same issues, even if they are not explicitly mentioned in this review.

So I searched. That block had six relatives I had forgotten about: inline onsubmit= handlers spread across two admin pages.

// Before β€” inline handler in the middle of a template
<form onsubmit="return confirm('Delete permanently?');">

// After β€” a data attribute, bound by the enqueued script
<form data-janitorix-confirm="Delete permanently?">

The plugin emits no inline JavaScript at all now. Fixing only the quoted line would probably have cost me another round, and their warning about that is not gentle: updates that resolve only a small portion of the reported issues may be rejected, and plugins rejected that way "will not be reviewed again."

Round 1: a three-letter prefix

Mine was uic. Their rule is four characters minimum, and the handbook recommends five. It is not only function names either: classes, define(), every option and transient key, shortcodes, post types, registered scripts, AJAX actions, namespaces.

Replacing it was the biggest mechanical job of the whole review. A nine-character prefix had to reach constants, options, the cron event, seven admin_post_* actions, three public hooks, five database tables, the WP-CLI command, the CSS classes.

Here is the part that stings. My phpcs.xml.dist excluded the short-prefix sniff, under a comment I had written myself arguing that uic was fine because it had "already shipped". PHPCS had been telling me the truth the whole time. I had told it to be quiet.

Round 2: sanitizing input

The name and slug were accepted, and this time a person had read the code. Three issues, all security.

The first:

// Before
'from' => $this->date( isset( $_GET['from'] ) ? wp_unslash( $_GET['from'] ) : '' ),
// phpcs:ignore ...InputNotSanitized -- date() validates against a strict Y-m-d whitelist

My defence was true. date() validates against ^\d{4}-\d{2}-\d{2}$ and throws away anything else, so nothing unvalidated ever reached a query.

It still deserved the flag. There is no sanitize_*() call in that line, and a phpcs:ignore sitting on top of it is a sign saying look here to anyone reading the file.

// After
'from' => $this->date( isset( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : '' ),

Sanitize first, validate after, ignore comment gone. Their mantra is sanitize early, escape late, always validate. Doing two out of three is not doing it.

Round 2: escaping output

They flagged a variable going into wp_add_inline_script(). The real problem sat one level below the quoted line, and I would not have found it from the line alone: my plugin shipped no asset files at all. The CSS and JavaScript lived inside PHP as heredoc strings, registered against a handle with no source file, then pushed out inline.

I had told myself this was clever. One fewer HTTP request. It is not clever: the browser cannot cache any of it, and anyone reading that file has to take a giant PHP string on trust.

They are real files now, enqueued by URL. The only thing still inlined is a small wp_json_encode() object with two translated strings and one setting.

The other half was a one-flag fix:

// Before
echo wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );

// After
echo wp_json_encode( $data, JSON_PRETTY_PRINT );

JSON_UNESCAPED_SLASHES switches off escaping that wp_json_encode() does deliberately.

Round 2: unsafe SQL

This was the big one. Every query built its table name by concatenation:

// Before
$wpdb->get_results(
    // phpcs:ignore -- Tables::logs() returns a hardcoded prefix + literal, never user input
    $wpdb->prepare( 'SELECT * FROM ' . Tables::logs() . ' WHERE attachment_id = %d', $id )
);

Their tooling even noted that prepare() was being called correctly, then flagged it anyway. When I swept the codebase myself it came to 112 query sites across 22 files.

My comment was, again, factually correct. Tables::logs() returns $wpdb->prefix plus a hardcoded literal, and no user input can reach it. But "my helper is safe" is not something a reviewer can verify at a glance, and it is not something a static analyser can verify at all.

WordPress 6.2 added %i, an identifier placeholder, for exactly this:

// After
$wpdb->get_results(
    $wpdb->prepare( 'SELECT * FROM %i WHERE attachment_id = %d', Tables::logs(), $id )
);

Every table identifier goes through %i now, mine as well as core's $wpdb->posts, postmeta and the rest. For IN ( … ) lists they handed me the pattern outright:

// Before
$in = implode( ',', array_map( 'intval', $ids ) );

// After
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$wpdb->prepare( "SELECT … WHERE id IN ( $placeholders )", $ids );

Yes, intval() on every element was already safe. That is not the point. The next person to edit that line is me in six months, and I have to spot the intval to keep it safe. With %d I have to spot nothing at all.

One knock-on effect: %i needs WordPress 6.2, so Requires at least moved up from 6.0. That version is from April 2023, and the plugin had never been published, so there was nobody to break.

The mistake I made twice

When round 2 arrived, Plugin Check reported 35 SQL warnings.

My own composer lint reported 0 errors, 0 warnings.

Because phpcs.xml.dist had this in it:

<exclude name="WordPress.DB.PreparedSQL.InterpolatedNotPrepared"/>
<exclude name="WordPress.DB.PreparedSQL.NotPrepared"/>
phpcs.xml.dist excluding two PreparedSQL sniffs, and a terminal where the same ruleset reports no violations while those two sniffs, switched back on, report 17 errors
The two lines that hid the problem, with the justification I wrote myself sitting above them. Run with the project's own ruleset the linter has nothing to say. Switch those two sniffs back on and the same files report 17 errors. Plugin Check, running a wider set of SQL checks, counted 35.

The exact two sniffs the reviewer flagged. Same shape as the prefix in round 1. Twice in one review, I had switched off the check that would have caught the thing that pended me.

Both exclusions are gone, and the code passes with the sniffs enabled, which is the only version of that report worth anything. A green run from a config you wrote yourself proves nothing.

Never silence a sniff to make a report look green. If a rule genuinely does not apply, silence that one line with a comment explaining why. Not the whole rule, project-wide, in a file you will forget you edited.

Four warnings are still there on purpose, in a dynamic WHERE builder and a bulk INSERT. Both assemble a query string from literal SQL fragments and placeholders, never from a value. If a reviewer asks, that is my answer, and it is a far better position than having silenced them.

What lint will never catch

%i cannot take a qualified column name. I had written %i for things like r.status. WordPress quotes that as one identifier β€” `r.status` β€” which is not a real column, and it would have broken my filters silently. PHPCS was happy. PHPStan was happy. It turned up when I clicked the filter on a live site and nothing came back.

Plugin Check scans your whole folder β€” vendor/, tests/, tools/, none of which ship. Checking my working directory produced an alarming number. The one that counts comes from the built ZIP, installed under the right folder name. Get the folder name wrong and it invents hundreds of bogus text-domain errors on its own.

A terminal showing Plugin Check run against the built plugin ZIP, reporting 158 warnings by check and no errors
The check that counts: the shipped ZIP, extracted under its real slug, rather than the working folder. Plenty of warnings, every one of them a deliberate direct database call. No errors, which is the part that matters, because only the Plugin repo category has to pass.

There was also an afternoon when I was certain I had introduced a bug: a freshly uploaded image would not trash. It was my own 24-hour safety rule doing its job. On a brand-new test site every image is under 24 hours old. Which made me realise a real user would hit the same thing on day one and decide the plugin was broken, so I added a notice explaining why a row is protected. Nobody asked for that. It was the most useful change I made all month.

Replying to the review

They are unusually direct about what they want back:

Your reply is brief and to the point… there is no need to describe every change you made, and please avoid unnecessary verbosity or AI-generated filler.

Mine ran to four sentences. That I had fixed all three issues. That I had swept the rest of the codebase for the same patterns rather than only the lines they listed. And a note about the minimum WordPress version going up, so that would not look odd. Nothing else. No changelog, no walkthrough of each fix.

Reply on the same thread, not a new email and not to the upload confirmation. That thread is what puts you back in the queue.

Approved β€” and still not live

Five days after that reply, two emails arrived one minute apart. Both said approved.

I assumed that was the finish line. It is not. It is a handover, and there are four things in it worth knowing before you get there.

Nothing is public until you upload it yourself. Approval grants your account commit access to a Subversion repository, usually within an hour. Your public plugin page exists as a URL, but it stays invisible until you push your files. Their wording is blunt: "we are unable to do that for you." No SVN push, no plugin.

SVN here is a release system, not Git. That sentence is in their email, and it is the switch that matters. You do not push work in progress and tidy up later. You push versions that are ready to install.

Your SVN password is not your WordPress.org password. You generate it separately, in the Account & Security section of your profile. Your username is your WordPress.org username, case sensitive, never your email address. Two of the most common first-day failures are sitting right there.

Then you wait again. The directory is big enough that search results and your profile page can take up to 72 hours to catch up. Live and findable are not the same day.

One more line from that email is worth reading twice:

A successful review does not guarantee that your plugin is entirely free from security issues or guideline violations β€” we are all human and mistakes happen.

From that point on the plugin can be reviewed at any time: by the Plugins Team, by independent experts, by community members, by automated scanners. If something turns up it can be closed, temporarily or permanently. They also ask you to whitelist their email address, because if they cannot reach you, that on its own is grounds for closing your plugin.

Approval is not a certificate. It is the start of the part where you keep the thing compliant.

FAQ

Is the first review email written by a human?
Often not. Mine said outright that it was an automated pre-review and might not have been read by a human, with the machine-generated parts marked ✨. The second round was explicitly a manual check by a volunteer.
How long does a review take?
There is no fixed time. Mine ran from late July to 20 August 2026, about three and a half weeks across two rounds, with roughly two weeks between replies. Their email says you may hear back within a few days, a week or two, or occasionally longer. They are volunteers. Do not ask for a status update unless you have been waiting a month or more, because by their own account it slows the queue down.
Can a plugin be rejected permanently?
Yes. Updates that resolve only a small portion of the reported issues may be rejected, and plugins rejected that way "will not be reviewed again."
Do I have to use the name they suggest?
No. You have to solve the problem, which is a name that is too generic or too close to something already there. What you replace it with is your choice.
Do I need to fix every Plugin Check warning?
No. Plugin Check's own FAQ says only the Plugin repo category has to pass. What matters is that the warnings you leave behind are ones you can defend.
What if I think they are wrong?
Their email invites it. It says there may be false positives, and asks you to request clarification clearly and with an example. Fix everything else first, then ask. Do not hold up a whole round over one disputed line.
Is my plugin live as soon as it is approved?
No. Approval gives you SVN commit access. Until you push the files yourself the public page stays empty, and it can take up to 72 hours after that for search results to catch up.

Where this stands

The plugin was approved on 20 August 2026 and it is in the directory now, as Janitorix Media Audit. That is the plugin all of this was about. If you want the version of the story that is about media libraries rather than review queues, I wrote that up separately.

Both rounds stung, and both were fair. Nothing in either email was an ambush. Every item was something my own tooling had already told me, or would have, if I had not gone out of my way to switch it off. The code that went live is in better shape than what I first submitted, which is the point of the whole thing.

If you are about to submit a plugin, do three things first. Search the directory for your name, lookalike meanings included. Delete every exclusion from phpcs.xml.dist and see what falls out. Run Plugin Check on the built ZIP, not on your working folder.

That is most of a review round, and you can do it this afternoon.

Related guides

Written by Monowar Hossain β€” WordPress Developer specializing in custom themes, plugins, Elementor, ACF, and performance optimization. Open Source Contributor. Also known online as devmonowar.

Published 21 August 2026 Β· Last updated 21 August 2026