A measured comparison of the most-installed wordpress.org plugins that overlap with Keel's disable-style defaults, focused on the two surfaces that are easy to get wrong: the REST API and the comment teardown.
Nothing here is taken from readmes or marketing copy. Every cell in the matrix is a live HTTP or PHP probe against a real install with that plugin active and configured the way its own settings screen would configure it.
- Lab: a throwaway WordPress 7.0.2 install (SQLite, PHP 8.5,
php -S), block theme, one seeded comment on post 1, pretty permalinks,ping_status=open. Deliberately not the Studio site — an always-on managed plugin there was filteringpings_open, stripping XML-RPC methods and answering comment queries empty, which silently contaminated the first run. - One plugin active at a time, activated, configured, probed, deactivated. Configuration was replicated from each plugin's own save handler, not guessed — for Disable Comments RB that mattered: its "everywhere" radio also writes a snapshot of every comment-supporting post type, and probing without it produced a false "does nothing" result.
- ~30 probes per plugin across five categories: anonymous REST reads (pretty
route,
?rest_route=,_embed), REST collateral (index, users, posts, oEmbed, discovery link), comment feeds,X-Pingback, XML-RPC (system.listMethodsplus direct calls topingback.pingandwp.getUsersBlogs, which bypass the method list), comment write paths (wp-comments-post.phpandPOST /wp/v2/comments, with a direct$wpdbcheck afterwards so no filter can hide whether a row actually landed), server-side reads (get_comments,wp_count_comments,comments_open, post-type support,get_default_comment_status), rendered front-end HTML, and cookie+nonce authenticated admin requests — the last one is what catches plugins that break the block editor.
Raw per-probe output is in the appendix.
| Plugin | Active installs | Probed |
|---|---|---|
| Disable Comments 2.8.0 | 1,000,000+ | live |
| Admin and Site Enhancements 8.9.2 | 200,000+ | live |
| Disable XML-RPC 1.0.1 | 200,000+ | live |
| Disable XML-RPC API 2.1.7 | 100,000+ | live |
| Disable Comments RB 1.0.27 | 100,000+ | live |
| Disable Everything 0.4.1 | 30,000+ | live |
| Disable WP REST API 2.6.8 | 30,000+ | live |
| Disable Blog 0.5.5 | 20,000+ | live |
| Simply Disable Comments 0.3.1 | 6,000+ | live |
| Keel 0.1.0-dev | — | live |
| Classic Editor 1.7.0 | 9,000,000+ | code review |
| Disable Gutenberg 3.3.2 | 500,000+ | code review |
| Clearfy 2.4.3 | 50,000+ | code review |
| WP Master Toolkit 2.22.0 | 5,000+ | code review |
The plugin is one line: add_filter( 'xmlrpc_enabled', '__return_false' ). Measured
against stock WordPress, the only thing that changes is the fault code on an
authenticated method (403 → 405). Everything else is identical to having no
plugin at all:
xmlrpc.phpstill answers200system.listMethodsstill returns all 80 methodspingback.pingis still listed and still executessystem.multicallstill availableX-Pingbackstill advertised in the response headers
xmlrpc_enabled gates methods that call login(). Pingback is unauthenticated by
design, so it sails straight through — and pingback is the method behind XML-RPC
reflection/DDoS amplification and SSRF probing, i.e. the actual reason most people
install one of these plugins. Disable Everything's XML-RPC toggle and WP Master
Toolkit's xmlrpc_enabled line have the same shape (WPMT redeems itself by also
swapping wp_xmlrpc_server_class).
Doing it right looks like Keel's, Clearfy's, or Disable XML-RPC API's approach:
unset pingback.ping and pingback.extensions.getPingbacks from xmlrpc_methods,
strip the X-Pingback header, and — if you want the endpoint gone — replace
wp_xmlrpc_server_class or 403 the file. Clearfy additionally hooks xmlrpc_call
and wp_dies on pingback.ping, which is belt-and-braces but correct.
Its rest_authentication_errors callback never checks is_user_logged_in():
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( empty( $result ) && ! is_admin() ) {
return new WP_Error( 'rest_authentication_error', 'Forbidden', array( 'status' => 403 ) );
}
return $result;
}, 20 );is_admin() is false during a REST request, so the guard never fires. Probed with a
valid admin cookie and X-WP-Nonce, every endpoint returns 403 —
wp/v2/posts?context=edit, wp/v2/settings, wp/v2/types, wp/v2/block-types.
That is the block editor and most REST-backed admin UI, dead. Every other
REST-disabling plugin in the field gets this right.
Two smaller defects in the same plugin: unguarded $_SERVER['QUERY_STRING'] in the
user-enumeration branch (warning + "headers already sent" on servers that don't
always populate it), and its feed teardown wp_dies with HTTP 500 rather than
404/410 — monitoring and crawlers read that as an outage.
comments_open, comments_array and the REST layer only cover the theme's comment
template and the API. get_comments(), wp_count_comments(), a Recent Comments
widget shipped by another plugin, a custom WP_Comment_Query — all go straight to
the database and answer normally.
Measured get_comments( array( 'status' => 'approve' ) ) with comments "disabled":
| Disable Comments | …RB | Simply DC | ASE | Keel | |
|---|---|---|---|---|---|
get_comments() |
1 | 1 | 1 | 1 | 0 |
wp_count_comments()->approved |
1 | 1 | 1 | 1 | 0 |
Keel is the only one that short-circuits comments_pre_query. That is the design
call documented in includes/content.php, and the probe confirms it is the only
implementation in the field where "comments are off" is true below the presentation
layer.
Its "remove XML-RPC comments" setting unsets exactly one method:
public function disable_xmlrc_comments( $methods ) {
unset( $methods['wp.newComment'] );
return $methods;
}Method count drops 80 → 79. pingback.ping stays listed and reachable — and a
pingback is a comment row. So with the toggle on, the one XML-RPC path that can
still create comments on the site is the one left open.
2.8.0 added an allowlist so type=note (editorial Notes, stored as comments) keeps
working. In practice the allowlist defaults to empty:
private function get_allowed_comment_types() {
if ( ! isset( $this->options['allowed_comment_types'] ) || ! is_array( ... ) ) {
return array(); // Default: all special comment types disabled
}Probed with an admin cookie: wp/v2/comments, ?type=note and ?type=comment all
return 403. The Notes carve-out only exists if the user finds and ticks "Enable
Certain Comment Types". Worth noting that core helps here — WP 7.0 rejects the
type parameter for anyone who can't moderate (rest_forbidden_param, 401), so an
allowlist keyed on type can't be abused anonymously; the risk is only that it's
off by default.
It is a fork of Disable Comments 1.x and stops at the presentation layer. With
"everywhere" saved: comment submission is correctly blocked (wp-comments-post.php
→ 403, nothing lands in the DB) and feeds 403 — but GET /wp/v2/comments returns
every comment, _embed=replies returns them, all 80 XML-RPC methods including
wp.newComment remain, and on a block theme the Comments block still renders.
There's also a structural issue it shares with Disable Comments' per-type mode: the disabled post-type list is a snapshot taken when you press Save. Any post type registered later by a new plugin or theme isn't covered until you re-save the settings.
Disable WP REST API, Admin and Site Enhancements and Disable Everything all return
401/403 for /wp-json/, /wp-json/oembed/1.0/embed and every route. That breaks
other sites embedding your posts — silently, and on their sites, with nothing on
the affected site to show it happened.
Keel was the fourth. It no longer is (keel#32, 2026-08-04): oembed/1.0 stays
reachable past the gate, so the REST API is closed and embeds still work. Re-probed
with the gate on — /wp-json/ 401, /wp/v2/posts 401, /wp/v2/users 401,
oembed/1.0/embed 200.
The carve-out only became safe once oEmbed stopped disclosing the author (keel#31,
keel#34). Left alone it returns author_name and an author_url carrying the
account nicename — to exactly the anonymous caller the gate has just refused
/wp/v2/users. Opening the route without that fix would have reopened the
enumeration the gate exists to close.
None of the other three allowlist oembed/1.0.
Legend: ✅ correct ·
| Disable Comments | …RB | Simply DC | Disable WP REST API | Disable XML-RPC | Disable XML-RPC API | Disable Everything | Disable Blog | ASE | Keel | |
|---|---|---|---|---|---|---|---|---|---|---|
Anonymous GET /wp/v2/comments blocked |
✅ 403 | ❌ 200, all comments | ❌ 200, all comments | ✅ 401 | ❌ | ❌ | ✅ 403 | ✅ 401 | ✅ 401 | |
?rest_route= variant blocked too |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | |
_embed=replies leak closed |
✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Anonymous user enumeration closed | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
| Block editor still works (authed) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ 403 everywhere | ✅ | ✅ | |
| REST discovery link removed | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (fixed) |
| oEmbed provider kept working | ✅ | ✅ | ✅ | ❌ 401 | ✅ | ✅ | ❌ 403 | ❌ 404 | ❌ 401 | ✅ 200 (fixed) |
| Comments route reachable by admins | ❌ 403 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ 403 | ✅ | ✅ |
| Disable Comments | …RB | Simply DC | Disable Blog | ASE | Keel | |
|---|---|---|---|---|---|---|
comments_open() false |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
pings_open() false |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
wp-comments-post.php rejects |
✅ 403 | ✅ 403 | ✅ 403 | ✅ 403 | ✅ 403 | ✅ 403 |
| No comment row lands in DB | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
get_comments() answers empty |
❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
wp_count_comments() answers 0 |
❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
get_comments_number() reads 0 |
✅ | ❌ 1 | ❌ 1 | ❌ 1 | ✅ | ✅ (fixed) |
| Post-type support removed | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
get_default_comment_status() closed |
❌ open | ✅ closed | ❌ open | ❌ open | ❌ open | ✅ closed |
| Comment feeds blocked | ✅ 403 | ✅ 403 | ❌ 200 | ❌ 200 | ❌ 200 | ✅ 404 (fixed) |
X-Pingback header stripped |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Block-theme comment markup gone | ✅ | ❌ | ❌ | ✅ | ✅ (fixed) | |
| Comment blocks pulled from inserter | ❌ | ❌ | ❌ | ✅ PHP, 15 blocks |
| Disable XML-RPC | Disable XML-RPC API | Disable Everything | Disable Comments | ASE | Clearfy | WPMT | Keel | |
|---|---|---|---|---|---|---|---|---|
| Technique | xmlrpc_enabled |
403 the endpoint | xmlrpc_enabled |
unset wp.newComment |
403 the endpoint | unset methods + xmlrpc_call die |
xmlrpc_enabled + server class |
unset methods, per-capability |
| Methods left listed | ❌ 80 | ✅ 0 | ❌ 80 | ✅ 0 | ✅ | ✅ | ✅ 3 (system.*) |
|
pingback.ping unreachable |
❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Remote publishing blocked | ✅ | ❌ | ✅ | — | ✅ | ✅ | ||
system.multicall removable |
❌ | ✅ (all-or-nothing) | ❌ | ❌ | ✅ (all-or-nothing) | ❌ | ✅ (all-or-nothing) | ✅ individually |
| Granular (keep app publishing, drop pingback) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Classic Editor (9M) | Disable Gutenberg (500k) | Keel | |
|---|---|---|---|
use_block_editor_for_post_type |
✅ | ✅ | ✅ |
Per-post use_block_editor_for_post |
✅ | ❌ | ❌ |
| Per-user opt-in / both-editors mode | ✅ | ❌ single switch | |
| Unhooks Gutenberg-plugin REST routes | ✅ | ✅ | ❌ |
| Edit links, row actions, post states | ✅ | ❌ |
Classic Editor remains the reference implementation; nothing in this field improves
on it. Keel's force_classic_editor is a blunt site-wide switch by comparison —
appropriate for a defaults plugin, but it should say so in its help text rather than
imply parity.
Keel measures best-in-field on the two surfaces it was designed around: it is the only plugin that closes server-side comment reads, and its XML-RPC teardown is the only granular one (drop pingbacks, keep remote publishing, or any combination). Its REST gate is on the correct side of the authenticated/anonymous line.
The probes also found five gaps in Keel itself. All five are fixed. The fifth was recorded here as an inherent trade-off before it was closed; the entry below now says what actually shipped.
get_comments_number()returned 1 whilewp_count_comments()returned 0 — a theme printing "1 Comment" above a thread that no longer exists. Fixed:get_comments_numberfiltered to zero.- The REST discovery link was still emitted with
disable_reston, advertisingrel="https://api.w.org/"for an endpoint that answers 401. Fixed: all three discovery outputs unhooked —rest_output_link_wp_head,rest_output_link_headerandrest_output_rsd. - Block-theme comment markup still rendered — the Comments block wrapper, the
"Comments" heading and the block's CSS shipped on every post. The inserter filter
only governs what an editor can add next; it does not touch blocks already saved
in a theme's templates. Fixed:
render_blockreturns an empty string for the comment blocks, which leaves the blocks registered and the template markup intact so the default stays reversible. - Comment feeds returned 200 — empty, thanks to
comments_pre_query, but live and crawlable. This was the clearest of the four, because thedisable_commentshelp text already claimed comment feeds were removed; only the<link>markup was. Fixed: comment feed requests 404. - oEmbed went down with the REST API — shared with every other plugin that
blocks anonymous REST, and recorded here at first as an acceptable cost of the
toggle. It was not: the site paying it is the one doing the embedding, so the
breakage lands somewhere the operator never sees. Fixed (keel#32):
oembed/1.0is allowlisted past the gate, and thedisable_resthelp text says so. See finding 7 above for the re-probe. The carve-out is only safe becausekeel_defaults_strip_oembed_author()is registered by the gate itself, so the route cannot hand an anonymous caller the nicenames the gate just refused.
Fix 4 needed a second pass. Calling set_404() alone produced a worse result than
the bug: redirect_canonical() does not bail on a 404 — it calls
redirect_guess_404_permalink(), and against the query set_404() had just emptied
it answered /hello-world/feed/ with a 301 to /hello-world/feed/feed/. The
canonical redirect has to be removed for that request too. Only the HTTP probe
caught this; every filter-level assertion still passed.
None of the four were Keel-specific. All three of the plugins in this lineage —
Keel, Better by Default and the Pixel Managed Platform — share them, because they
share the code they came from. A three-way matrix between siblings
(~/Code/keel-px-feature-matrix.md) had already given the comment teardown a
full read-verdict and found nothing, which is what a sibling comparison does: it
sees divergence, never common inheritance. These surfaced only against unrelated
plugins, where Disable Comments and Admin and Site Enhancements turned out to be
measurably ahead of all three of ours on rendered markup and comment counts. The
back-ports are filed there as B-8/B-9 (Better by Default) and P11/P12 (Pixel);
both landed the same day and were re-probed — all three plugins now return
identical results across all 30 probes.
Settling it also turned up a reporting bug one layer out. Pixel's Site Health
posture counted comment_status = 'open' rows straight from the database, so on a
site where the teardown was fully on and nothing could post a comment through any
route, the panel still flagged "Open comments" as a live public-input surface. The
stored status is a candidate, not the answer — every core write path gates on
comments_open(), which is a filter.
That one was fixed upstream, independently and first: Pixel's #218 landed while
this comparison was being written, and short-circuits the count on
Comments::instance()->comments_are_disabled(). Worth recording that it closes the
case narrowly. It reports the effective state for Pixel's own toggle, so a site
running Pixel alongside a third-party comment plugin is still flagged for open
comments it cannot receive — and it reaches from PluginContext back into the
Comments module, which is the coupling that object's own docblock says it exists to
avoid. Asking comments_open directly would cover any teardown and need no such
dependency; that is a preference, not a defect, and the shipped fix has the tests.
Coverage for all four landed in tests/integration/verify-behaviors.sh (48 checks,
all passing). That harness also had a bug of its own: it routed any site with a
wp-content/db.php dropin through studio wp, which made the documented
KEEL_SITE override unusable on exactly the kind of throwaway SQLite install this
comparison needs. It now keys off the path instead.
Every value is a live measurement. HTTP status codes unless noted; n= is the
number of comments returned in the JSON body; fault= is the XML-RPC fault code
from a direct method call (-32601 = method not found, none = no response body,
405 = "XML-RPC services are disabled").
| probe | stock WP | disable-comments | …-rb | simply-dc | disable-wp-rest-api | disable-xml-rpc | disable-xml-rpc-api | disable-everything | disable-blog | ASE | Keel (fixed) |
|---|---|---|---|---|---|---|---|---|---|---|---|
rest.comments.pretty |
200 (n=1) | 403 (n=err) | 200 (n=1) | 200 (n=1) | 401 (n=err) | 200 (n=1) | 200 (n=1) | 403 (n=err) | 200 (n=0) | 401 (n=err) | 401 (n=err) |
rest.comments.querystring |
200 (n=1) | 403 (n=err) | 200 (n=1) | 200 (n=1) | 401 (n=err) | 200 (n=1) | 200 (n=1) | 403 (n=err) | 200 (n=0) | 401 (n=err) | 401 (n=err) |
rest.comments.embed |
n=1 | n=0 | n=1 | n=1 | n=0 | n=1 | n=1 | n=0 | n=0 | n=0 | n=0 |
rest.index |
200 | 200 | 200 | 200 | 401 | 200 | 200 | 403 | 200 | 401 | 401 |
rest.users |
200 | 200 | 200 | 200 | 401 | 200 | 200 | 403 | 200 | 401 | 401 |
rest.posts |
200 | 200 | 200 | 200 | 401 | 200 | 200 | 403 | 404 | 401 | 401 |
rest.oembed |
200 | 200 | 200 | 200 | 401 | 200 | 200 | 403 | 404 | 401 | 200 |
rest.head_link |
1 | 1 | 1 | 1 | 0 | 1 | 1 | 0 | 1 | 0 | 0 |
feed.site_comments |
200 | 403 | 403 | 200 | 200 | 200 | 200 | 500 | 200 | 200 | 404 |
feed.post_comments |
200 | 403 | 403 | 200 | 200 | 200 | 200 | 500 | 200 | 200 | 404 |
header.xpingback |
1 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
xmlrpc.http |
200 | 200 | 200 | 200 | 200 | 200 | 403 | 200 | 200 | 403 | 200 |
xmlrpc.methods |
80 | 79 | 80 | 79 | 80 | 80 | 0 | 80 | 50 | 0 | 3 |
xmlrpc.has_pingback |
1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
xmlrpc.has_multicall |
1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 1 | 0 | 1 |
xmlrpc.has_newPost |
1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
xmlrpc.has_newComment |
1 | 0 | 1 | 0 | 1 | 1 | 0 | 1 | 1 | 0 | 0 |
xmlrpc.direct_pingback |
fault=0 | fault=0 | fault=0 | fault=0 | fault=0 | fault=0 | fault=none | fault=0 | fault=-32601 | fault=none | fault=-32601 |
xmlrpc.direct_login |
fault=403 | fault=403 | fault=403 | fault=403 | fault=403 | fault=405 | fault=none | fault=405 | fault=-32601 | fault=none | fault=-32601 |
write.wp-comments-post |
302 | 403 | 403 | 403 | 302 | 302 | 302 | 302 | 403 | 403 | 403 |
write.rest_post |
401 | 403 | 401 | 401 | 401 | 401 | 401 | 403 | 401 | 401 | 401 |
write.comment_landed_indb |
1 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 0 | 0 | 0 |
html.comment_form |
6 | 0 | 1 | 0 | 6 | 6 | 6 | 6 | 1 | 0 | 0 |
html.comments_block |
4 | 0 | 4 | 1 | 4 | 4 | 4 | 4 | 4 | 0 | 0 |
auth.posts_edit |
200 | 200 | 200 | 200 | 200 | 200 | 200 | 403 | 404 | 200 | 200 |
auth.settings |
200 | 200 | 200 | 200 | 200 | 200 | 200 | 403 | 200 | 200 | 200 |
auth.types |
200 | 200 | 200 | 200 | 200 | 200 | 200 | 403 | 403 | 200 | 200 |
auth.block_types |
200 | 200 | 200 | 200 | 200 | 200 | 200 | 403 | 200 | 200 | 200 |
auth.comments |
200 | 403 | 200 | 200 | 200 | 200 | 200 | 403 | 200 | 404 | 200 |
php.get_comments |
1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 |
php.typed |
1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 |
php.wp_count_comments |
1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 0 |
php.comments_open |
1 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 0 | 0 | 0 |
php.pings_open |
1 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
php.number |
1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 |
php.supports |
1 | 1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 |
php.default_status |
open | open | closed | open | open | open | open | open | open | open | closed |