Why the filter has to run before the insert
Most comment-spam tutorials bolt onto wp_insert_comment or a cron sweep. By then the row already exists, the spam is in your database, and every cleanup is a delete rather than a prevention. WordPress gives you a cleaner seam: the pre_comment_approved filter fires inside wp_new_comment() before the row is written, and its return value decides the stored status — 1 for approved, 0 for pending, 'spam' for the spam bin, 'trash' for the bin.
That filter is also the seam used by the REST comments controller, so anything posting to /wp-json/wp/v2/comments is screened by the same code path. One hook, two entry points.
This tutorial wires that filter to OpenAI’s Moderation endpoint with a hard timeout, a category-to-status policy, a failure policy you choose, a decision log, and per-submitter rate limiting. It is deliberately blocking: you cannot know the verdict before the insert without waiting for the verdict.
Prerequisites
- WordPress 6.4 or newer, PHP 8.0+, and outbound HTTPS from the server (many managed hosts block it by default).
- An OpenAI API key with access to the Moderation endpoint.
omni-moderation-latestis free to call, which matters because comment volume is unpredictable. - Ability to edit
wp-config.phpand add a small mu-plugin. - A staging site. You are about to change what happens to every comment on the site.
Put the code in wp-content/mu-plugins/comment-moderation.php so it cannot be deactivated by accident from the plugins screen.
Step 1 Put the key and the policy in wp-config.php
Constants beat options for secrets: they are not autoloaded into every page request, they are not exposed by a settings-page mistake, and they are not written to the database backup that gets emailed around. Define them above the stop editing line.
// wp-config.php
// Never commit this file to a public repository.
define( 'WPTUTS_OPENAI_KEY', 'sk-proj-replace-me' );
// 'closed' = hold the comment for a human when the API is unreachable.
// 'open' = let the comment through as normal when the API is unreachable.
define( 'WPTUTS_MODERATION_FAILURE_POLICY', 'closed' );
// Category score at or above this counts as a hit (0.00 - 1.00).
define( 'WPTUTS_MODERATION_THRESHOLD', 0.55 );
Why a threshold at all? The endpoint returns both a boolean flagged and per-category category_scores. The boolean uses OpenAI’s own cut-offs, which are tuned for general content, not for a blog comment box where a heated argument about politics is normal but a threat is not. Reading the scores yourself lets you tune the sensitivity per category instead of accepting one global switch.
Step 2 Build a moderation client with a hard timeout
Use wp_remote_post() rather than raw cURL so proxies, SSL certificates, and the WP_HTTP_BLOCK_EXTERNAL constant are handled consistently. The timeout is the important number: a comment form that hangs for thirty seconds is a denial-of-service on your own readers.
function wptuts_moderate_text( string $text ): ?array {
$key = defined( 'WPTUTS_OPENAI_KEY' ) ? WPTUTS_OPENAI_KEY : '';
$text = trim( $text );
if ( '' === $key || '' === $text ) {
return null;
}
// Identical text (bots repeat themselves) should not cost a second round trip.
$cache_key = 'wptuts_mod_' . md5( strtolower( $text ) );
$cached = get_transient( $cache_key );
if ( is_array( $cached ) ) {
return $cached;
}
$response = wp_remote_post(
'https://api.openai.com/v1/moderations',
array(
'timeout' => 3,
'redirection' => 0,
'blocking' => true,
'headers' => array(
'Authorization' => 'Bearer ' . $key,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(
array(
'model' => 'omni-moderation-latest',
'input' => mb_substr( $text, 0, 4000 ),
)
),
)
);
if ( is_wp_error( $response ) ) {
error_log( 'WPTuts moderation transport: ' . $response->get_error_message() );
return null;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'WPTuts moderation HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response ) );
return null;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! isset( $body['results'][0]['category_scores'] ) ) {
return null;
}
set_transient( $cache_key, $body['results'][0], HOUR_IN_SECONDS );
return $body['results'][0];
}
Returning null for every failure mode — no key, transport error, non-200, malformed body — keeps the caller simple: null means no verdict, and the failure policy decides what that means.
Step 3 Map categories to pending or spam
Split the categories into two tiers. Tier one is content you never want published and never want a moderator to accidentally approve: sexual content involving minors, threatening hate, threatening harassment, illicit violent instructions. Tier two is content that may be legitimate but needs a human eye: general harassment, hate, self-harm, sexual, violence, graphic violence.
function wptuts_comment_decision( array $result ): string {
$threshold = defined( 'WPTUTS_MODERATION_THRESHOLD' ) ? (float) WPTUTS_MODERATION_THRESHOLD : 0.55;
$scores = (array) $result['category_scores'];
$never_publish = array(
'sexual/minors',
'hate/threatening',
'harassment/threatening',
'illicit/violent',
);
$hold_for_human = array(
'harassment',
'hate',
'self-harm',
'self-harm/intent',
'self-harm/instructions',
'sexual',
'violence',
'violence/graphic',
);
foreach ( $never_publish as $category ) {
if ( isset( $scores[ $category ] ) && $scores[ $category ] >= $threshold ) {
return 'spam';
}
}
foreach ( $hold_for_human as $category ) {
if ( isset( $scores[ $category ] ) && $scores[ $category ] >= $threshold ) {
return 'hold';
}
}
return 'approve';
}
Note that 'spam' is a string, not a boolean. Returning true from the filter approves the comment; returning the string 'spam' is what routes it to the spam bin. Getting this wrong is the single most common bug in this integration.
Step 4 Hook the filter and rate-limit repeat submitters
Rate limiting belongs before the API call. A bot firing two hundred comments a minute should be stopped by a transient counter, not by two hundred billable requests. Hash the IP and email together so a shared office NAT does not lock out a whole building, and keep the window short.
function wptuts_submitter_is_flooding( array $commentdata ): bool {
$fingerprint = ( $commentdata['comment_author_IP'] ?? '' ) . '|' . strtolower( $commentdata['comment_author_email'] ?? '' );
$key = 'wptuts_flood_' . md5( $fingerprint );
$hits = (int) get_transient( $key );
if ( $hits >= 3 ) {
return true;
}
set_transient( $key, $hits + 1, 10 * MINUTE_IN_SECONDS );
return false;
}
add_filter( 'pre_comment_approved', 'wptuts_screen_comment', 10, 2 );
function wptuts_screen_comment( $approved, array $commentdata ) {
// Trusted staff bypass the screen entirely.
if ( ! empty( $commentdata['user_id'] ) && user_can( (int) $commentdata['user_id'], 'moderate_comments' ) ) {
return $approved;
}
if ( wptuts_submitter_is_flooding( $commentdata ) ) {
wptuts_log_decision( $commentdata, 'rate_limited' );
return 0;
}
$text = trim(
( $commentdata['comment_content'] ?? '' ) . ' ' .
( $commentdata['comment_author'] ?? '' ) . ' ' .
( $commentdata['comment_author_url'] ?? '' )
);
$result = wptuts_moderate_text( $text );
if ( null === $result ) {
$policy = defined( 'WPTUTS_MODERATION_FAILURE_POLICY' ) ? WPTUTS_MODERATION_FAILURE_POLICY : 'closed';
wptuts_log_decision( $commentdata, 'api_failure' );
return 'open' === $policy ? $approved : 0;
}
$decision = wptuts_comment_decision( $result );
wptuts_log_decision( $commentdata, $decision, $result );
if ( 'spam' === $decision ) {
return 'spam';
}
return 'hold' === $decision ? 0 : $approved;
}
Fail closed or fail open? Failing closed (holding the comment) protects readers but means an OpenAI outage silently swallows every comment until someone notices. Failing open keeps conversation flowing but lets a determined spammer through during that same outage. For a low-traffic personal blog, fail open. For a brand site with legal exposure, fail closed and monitor the log. The constant makes the choice explicit rather than accidental.
Step 5 Log every decision
Without a log you cannot answer the only question that matters after a false positive: what did the model actually score? Store hashed identifiers rather than raw IPs and emails, and store only a short excerpt of the comment so the log itself does not become a personal-data liability.
CREATE TABLE {$wpdb->prefix}wptuts_moderation_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL,
ip_hash CHAR(64) NOT NULL,
email_hash CHAR(64) NOT NULL,
decision VARCHAR(20) NOT NULL,
top_category VARCHAR(40) DEFAULT NULL,
top_score DECIMAL(5,4) DEFAULT NULL,
excerpt VARCHAR(190) NOT NULL,
PRIMARY KEY (id),
KEY decision (decision),
KEY created_at (created_at)
) {$charset_collate};
Create it once with dbDelta() guarded by an option flag, then write to it from the logger. Note the third argument to $wpdb->insert() — the format array — is a separate parameter, not part of the data array.
function wptuts_log_decision( array $commentdata, string $decision, ?array $result = null ): void {
global $wpdb;
$scores = ( is_array( $result ) && isset( $result['category_scores'] ) ) ? (array) $result['category_scores'] : array();
arsort( $scores );
$wpdb->insert(
$wpdb->prefix . 'wptuts_moderation_log',
array(
'created_at' => current_time( 'mysql', true ),
'ip_hash' => hash( 'sha256', (string) ( $commentdata['comment_author_IP'] ?? '' ) ),
'email_hash' => hash( 'sha256', strtolower( (string) ( $commentdata['comment_author_email'] ?? '' ) ) ),
'decision' => $decision,
'top_category' => $scores ? (string) key( $scores ) : null,
'top_score' => $scores ? (float) current( $scores ) : null,
'excerpt' => mb_substr( wp_strip_all_tags( (string) ( $commentdata['comment_content'] ?? '' ) ), 0, 180 ),
),
array( '%s', '%s', '%s', '%s', '%s', '%f', '%s' )
);
}
Query it weekly: SELECT decision, top_category, COUNT(*) FROM wp_wptuts_moderation_log GROUP BY decision, top_category ORDER BY 3 DESC; A category that fires constantly at 0.56 is a threshold problem, not a spam problem.
Step 6 Capability-safe admin overrides
The failure policy should be switchable without editing files, but only by someone who already has manage_options, and only through a nonce-checked POST. Never read a policy from $_GET, and never let a subscriber influence moderation behaviour.
add_action( 'admin_menu', function () {
add_options_page( 'Comment Moderation', 'Comment Moderation', 'manage_options', 'wptuts-moderation', 'wptuts_render_settings' );
} );
function wptuts_render_settings(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to change moderation policy.', 'wptuts' ) );
}
if ( isset( $_POST['wptuts_policy'] ) ) {
check_admin_referer( 'wptuts_save_policy' );
$policy = ( 'open' === $_POST['wptuts_policy'] ) ? 'open' : 'closed';
update_option( 'wptuts_moderation_policy', $policy );
echo '<div class="notice notice-success"><p>Policy saved.</p></div>';
}
$policy = get_option( 'wptuts_moderation_policy', 'closed' );
echo '<form method="post">';
wp_nonce_field( 'wptuts_save_policy' );
foreach ( array( 'closed' => 'Hold for a human when the API fails', 'open' => 'Publish when the API fails' ) as $value => $label ) {
printf(
'<p><label><input type="radio" name="wptuts_policy" value="%1$s" %2$s> %3$s</label></p>',
esc_attr( $value ),
checked( $policy, $value, false ),
esc_html( $label )
);
}
submit_button( 'Save policy' );
echo '</form>';
}
Then read get_option( 'wptuts_moderation_policy' ) in wptuts_screen_comment() and fall back to the constant only when the option is empty. Also register a privacy-policy entry with wp_add_privacy_policy_content() — you are sending comment text to a third party, and that belongs in the disclosure, not in a footnote.
Troubleshooting
- HTTP 401 on every comment. The constant is defined after
require_once ABSPATH . 'wp-settings.php';, or in a theme file. Move it intowp-config.phpabove the stop-editing line and confirm withdefined( 'WPTUTS_OPENAI_KEY' ). - Comments vanish instead of going to spam. You returned
trueor1where you meant'spam'. The filter accepts1,0,'spam','trash', andWP_Error— nothing else. - Every submission times out. Outbound HTTPS is blocked. Check
WP_HTTP_BLOCK_EXTERNALandWP_ACCESSIBLE_HOSTS, then test withwp_remote_get( 'https://api.openai.com/v1/models' )from WP-CLI. - HTTP 429. You are hitting the rate limit. The transient cache in step 2 handles repeated identical text; for genuinely high volume, batch the
inputarray instead of sending one request per comment. - Staff comments get held. The
user_can()bypass relies oncommentdata['user_id'], which is only set for logged-in commenters. Anonymous staff are anonymous. - Nothing is logged. The table was never created because
admin_inithas not run since you deployed. Visit wp-admin once, or run thedbDelta()call from WP-CLI.
Next steps
The blocking approach costs latency on every submission. If that becomes a problem, invert the design: always insert as 0 (pending), then re-check asynchronously with wp_schedule_single_event() and promote to 1 or demote to 'spam' from the cron job. You keep the pre-database guarantee for the risky cases and move the common case off the request path.
From there, three extensions worth building: a dashboard widget that surfaces the last twenty hold decisions with their top category, a weekly digest email of threshold hits so you notice drift, and reuse of wptuts_moderate_text() on your contact form handler — the client function is deliberately decoupled from comments so it drops straight into any wp_remote_post workflow.
Leave a Reply