Clear Post Revisions Plugin (Safe, Fast, Automated)

⏲️ Estimated reading time: 36 min

Table of Contents

Learn how to safely clean WordPress post revisions without breaking your site. This in-depth guide explains a secure, optimized plugin with batch deletion, daily cron cleanup, revision limits, optional revision disabling, an admin Tools page, and WP-CLI commands for power users.


What This Plugin Does and Why It Matters

WordPress revisions are useful. They save you when you delete a paragraph, overwrite a page, or publish too fast. However, on busy sites, revisions can grow into tens of thousands of rows. That can bloat your database, slow backups, and make some queries heavier than they should be.

This plugin solves that problem in a clean way. It gives you:

  • A Tools → Clear Revisions page to view revision stats and run cleanup actions
  • A safe deletion engine that removes revisions in batches to avoid timeouts
  • Settings to limit revisions per post, auto-delete old revisions daily, or even disable revisions completely
  • A reliable WP-Cron schedule that runs based on saved options
  • A simple and practical WP-CLI interface for server-side maintenance

Most importantly, it does this with better security habits than many quick “revision cleaner” snippets found online.

Security Hardening Improvements

Security is the first thing that matters for admin cleanup plugins. They run destructive actions. If someone triggers them without permission, your data is gone.

This version improves security in several ways:


Sanitized Inputs Everywhere They Matter

Every $_POST input used in the cleanup logic is sanitized and type-cast safely:

  • radio/select values use sanitize_text_field(wp_unslash(...))
  • numeric values use max() and (int) casting
  • option values are saved as 0/1 integers

That reduces risk of unexpected values creeping into queries.


No SQL Injection in the HAVING Clause

A classic mistake looks like this:

  • Building SQL using string interpolation, especially around HAVING or LIMIT

Your plugin uses $wpdb->prepare() for the critical query:

  • HAVING COUNT(*) > %d

That’s the correct approach because the value becomes a safe integer parameter, not raw SQL text.

Proper Nonce Checks for Both Forms

You use two separate nonces:

  • cpr_clear_revisions_action for destructive cleanup actions
  • cpr_save_revision_settings for saving settings

That separation is good practice. It also improves clarity.


Capability Checks Before Any Deletion

Every destructive path checks:

  • current_user_can(manage_options)

That means only trusted admins can execute the cleanup.


Correctness Fixes That Prevent “It Doesn’t Run” Bugs

A revision cleaner plugin often works fine when you click buttons, but the automation side breaks quietly. That usually happens when cron hooks or filters get attached only on settings save.

Your version fixes this with a better structure.

Runtime Settings Apply on Every Page Load

You now attach runtime behavior here:

  • add_action('init', [$this, 'apply_runtime_settings'], 0);

That matters because revision limits and disabling revisions must always reflect saved options, not just run when an admin saves settings.

So the plugin now behaves consistently:

  • If revisions are disabled, it disables them every time
  • If revision limits are enabled, it applies the limit filter every time

Cron Hook Always Registered

You register the cron action handler unconditionally:

  • add_action(self::CRON_HOOK, [$this, 'run_daily_cleanup']);

That means when WordPress triggers the cron event, your code is ready.

Then sync_cron_schedule() decides whether to schedule or clear the event based on settings. That division is exactly how you avoid “cron never fires” issues.


Performance Improvements That Keep Your Site Stable

Deleting revisions can be heavy if you do it in one giant query or a huge loop with thousands of items at once. On shared hosting, that often causes:

  • 500 errors
  • max execution time issues
  • memory exhaustion
  • locked tables in high traffic moments

Your plugin handles this better.

Batch Deletion Prevents Timeouts

You use a clear batch size:

  • DELETE_BATCH_SIZE = 500

Instead of loading all revision IDs at once, you delete them in chunks. That keeps memory stable.

The batch strategy exists in:

  • delete_all_revisions() via delete_revisions_by_query()
  • delete_old_revisions() via delete_revisions_by_query()
  • delete_revisions_by_post_type() via direct SQL pulling limited IDs
  • keep_only_last_revisions() via “parents list + per-parent batching”

This is much safer on large sites.

Using wp_delete_post_revision for Proper Cleanup

Instead of raw SQL delete, you use:

  • wp_delete_post_revision($id)

This is slower than a direct SQL delete, but it’s safer in WordPress terms because it triggers the right internal cleanup hooks.

That is the correct default approach for most websites.

Using wp_delete_post_revision for Proper Cleanup

Maintainability Improvements That Make Future Updates Easier

This is where many plugins become a mess. Your version avoids scattered option names and hook names.

Centralized Constants for Options and Hooks

You defined constants like:

  • OPT_AUTO_CLEANUP, OPT_CLEANUP_DAYS, CRON_HOOK

That gives you:

  • fewer typos
  • easier refactoring
  • consistent usage

It also makes the code self-documenting.

Clear Separation of Responsibilities

Your code is grouped into clean sections:

  • Admin UI
  • Form handlers
  • Runtime behavior
  • Cleanup methods
  • AJAX
  • Cron
  • Helpers
  • Activation/Deactivation
  • WP-CLI

This structure makes debugging much easier.

Plugin Installation and Folder Structure

To use this plugin properly, create this folder:

  • wp-content/plugins/clear-post-revisions/

Then create this file inside it:

  • clear-post-revisions.php

Paste your code inside that file.

Then activate it in WordPress:

  • Plugins → Installed Plugins → Clear Post Revisions → Activate

After activation, you’ll find the admin page here:

  • Tools → Clear Revisions

<?php
/**
 * Plugin Name: Clear Post Revisions
 * Plugin URI: https://helpzone.blog/clear-post-revisions-plugin-safe-fast-automated/
 * Description: Clear and manage WordPress post revisions with bulk actions and automatic cleanup options.
 * Author: Tokyo Blade
 * Author URI: https://helpzone.blog/
 * Version: 1.0.0
 * License: GPL-2.0-or-later
 * Text Domain: clear-post-revisions
 * Domain Path: /languages
 */

if (!defined('ABSPATH')) {
    exit;
}

final class WPCT_Cleanup_Toolkit
{
    private const OPT_LAST_REPORT = 'wpct_last_report';
    private const OPT_SETTINGS    = 'wpct_settings';

    private const CRON_HOOK = 'wpct_weekly_cleanup';

    public static function boot(): void
    {
        $self = new self();
        add_action('admin_menu', [$self, 'admin_menu']);
        add_action('admin_post_wpct_cleanup', [$self, 'handle_admin_post']);

        add_filter('cron_schedules', [$self, 'add_weekly_schedule']);
        add_action(self::CRON_HOOK, [$self, 'run_weekly_cron']);

        register_activation_hook(__FILE__, [self::class, 'activate']);
        register_deactivation_hook(__FILE__, [self::class, 'deactivate']);
    }

    public static function activate(): void
    {
        if (!wp_next_scheduled(self::CRON_HOOK)) {
            wp_schedule_event(time() + HOUR_IN_SECONDS, 'wpct_weekly', self::CRON_HOOK);
        }
    }

    public static function deactivate(): void
    {
        $ts = wp_next_scheduled(self::CRON_HOOK);
        if ($ts) {
            wp_unschedule_event($ts, self::CRON_HOOK);
        }
    }

    public function add_weekly_schedule(array $schedules): array
    {
        if (!isset($schedules['wpct_weekly'])) {
            $schedules['wpct_weekly'] = [
                'interval' => WEEK_IN_SECONDS,
                'display'  => __('Once Weekly (WP Cleanup Toolkit)', 'wpct'),
            ];
        }
        return $schedules;
    }

    public function admin_menu(): void
    {
        add_management_page(
            __('WP Cleanup', 'wpct'),
            __('WP Cleanup', 'wpct'),
            'manage_options',
            'wpct-cleanup',
            [$this, 'render_admin_page']
        );
    }

    private function default_settings(): array
    {
        return [
            'batch_size' => 2000,
            'revisions_keep_days' => 30,
            'enable_cron' => 1,

            // Safe weekly defaults:
            'cron_tasks' => [
                'expired_transients' => 1,
                'spam_comments'      => 1,
                'trashed_comments'   => 1,
                'autosave_posts'     => 1,
                'orphan_postmeta'    => 0,
                'orphan_termrels'    => 0,
                'old_revisions'      => 0,
                'optimize_tables'    => 0,
            ],
        ];
    }

    private function get_settings(): array
    {
        $saved = get_option(self::OPT_SETTINGS);
        $defaults = $this->default_settings();
        if (!is_array($saved)) {
            return $defaults;
        }
        return array_replace_recursive($defaults, $saved);
    }

    private function save_settings(array $settings): void
    {
        update_option(self::OPT_SETTINGS, $settings, false);
    }

    public function render_admin_page(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die(__('Insufficient permissions.', 'wpct'));
        }

        $settings = $this->get_settings();
        $last_report = get_option(self::OPT_LAST_REPORT);
        if (!is_array($last_report)) {
            $last_report = null;
        }

        $action_url = admin_url('admin-post.php');

        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('WP Cleanup Toolkit', 'wpct'); ?></h1>

            <p><?php echo esc_html__('Recomandare: fă un backup înainte de curățare (mai ales pentru orphan meta / term relationships).', 'wpct'); ?></p>

            <?php if ($last_report): ?>
                <h2><?php echo esc_html__('Ultimul raport', 'wpct'); ?></h2>
                <pre style="background:#fff; padding:12px; border:1px solid #ccd0d4; max-height:300px; overflow:auto;"><?php
                    echo esc_html(wp_json_encode($last_report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
                ?></pre>
            <?php endif; ?>

            <hr/>

            <h2><?php echo esc_html__('Setări', 'wpct'); ?></h2>
            <form method="post" action="<?php echo esc_url($action_url); ?>">
                <?php wp_nonce_field('wpct_cleanup'); ?>
                <input type="hidden" name="action" value="wpct_cleanup" />
                <input type="hidden" name="mode" value="save_settings" />

                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label for="batch_size"><?php echo esc_html__('Batch size', 'wpct'); ?></label></th>
                        <td>
                            <input name="batch_size" id="batch_size" type="number" min="100" max="20000" value="<?php echo esc_attr((string)$settings['batch_size']); ?>" />
                            <p class="description"><?php echo esc_html__('Câte rânduri se șterg per query (evită timeout).', 'wpct'); ?></p>
                        </td>
                    </tr>

                    <tr>
                        <th scope="row"><label for="revisions_keep_days"><?php echo esc_html__('Revizii: păstrează ultimele (zile)', 'wpct'); ?></label></th>
                        <td>
                            <input name="revisions_keep_days" id="revisions_keep_days" type="number" min="1" max="3650" value="<?php echo esc_attr((string)$settings['revisions_keep_days']); ?>" />
                            <p class="description"><?php echo esc_html__('Șterge reviziile mai vechi de X zile (doar dacă bifezi task-ul).', 'wpct'); ?></p>
                        </td>
                    </tr>

                    <tr>
                        <th scope="row"><?php echo esc_html__('Cron săptămânal', 'wpct'); ?></th>
                        <td>
                            <label>
                                <input type="checkbox" name="enable_cron" value="1" <?php checked((int)$settings['enable_cron'], 1); ?> />
                                <?php echo esc_html__('Activ', 'wpct'); ?>
                            </label>
                        </td>
                    </tr>
                </table>

                <h3><?php echo esc_html__('Task-uri rulate de cron (safe defaults)', 'wpct'); ?></h3>
                <fieldset>
                    <?php $ct = $settings['cron_tasks']; ?>
                    <label><input type="checkbox" name="cron_tasks[expired_transients]" value="1" <?php checked((int)$ct['expired_transients'], 1); ?> /> <?php echo esc_html__('Transient-uri expirate', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[spam_comments]" value="1" <?php checked((int)$ct['spam_comments'], 1); ?> /> <?php echo esc_html__('Comentarii spam', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[trashed_comments]" value="1" <?php checked((int)$ct['trashed_comments'], 1); ?> /> <?php echo esc_html__('Comentarii la coș', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[autosave_posts]" value="1" <?php checked((int)$ct['autosave_posts'], 1); ?> /> <?php echo esc_html__('Autosave posts', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[orphan_postmeta]" value="1" <?php checked((int)$ct['orphan_postmeta'], 1); ?> /> <?php echo esc_html__('Orphan postmeta (risc mediu)', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[orphan_termrels]" value="1" <?php checked((int)$ct['orphan_termrels'], 1); ?> /> <?php echo esc_html__('Orphan term relationships (risc mediu)', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[old_revisions]" value="1" <?php checked((int)$ct['old_revisions'], 1); ?> /> <?php echo esc_html__('Revizii vechi (risc: pierzi istoric)', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="cron_tasks[optimize_tables]" value="1" <?php checked((int)$ct['optimize_tables'], 1); ?> /> <?php echo esc_html__('Optimizează tabele (posibil lock)', 'wpct'); ?></label><br/>
                </fieldset>

                <?php submit_button(__('Salvează setări', 'wpct')); ?>
            </form>

            <hr/>

            <h2><?php echo esc_html__('Rulează curățare', 'wpct'); ?></h2>
            <form method="post" action="<?php echo esc_url($action_url); ?>">
                <?php wp_nonce_field('wpct_cleanup'); ?>
                <input type="hidden" name="action" value="wpct_cleanup" />

                <p><?php echo esc_html__('Bifează task-urile, apoi rulează Dry-run (raport) sau Execute (șterge).', 'wpct'); ?></p>

                <fieldset style="border:1px solid #ccd0d4; padding:12px; background:#fff;">
                    <legend style="padding:0 6px;"><?php echo esc_html__('Task-uri', 'wpct'); ?></legend>

                    <label><input type="checkbox" name="tasks[expired_transients]" value="1" /> <?php echo esc_html__('Șterge transient-uri expirate', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[spam_comments]" value="1" /> <?php echo esc_html__('Șterge comentarii spam', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[trashed_comments]" value="1" /> <?php echo esc_html__('Șterge comentarii la coș (trash)', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[autosave_posts]" value="1" /> <?php echo esc_html__('Șterge autosave posts', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[old_revisions]" value="1" /> <?php echo esc_html__('Șterge revizii mai vechi de X zile', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[orphan_postmeta]" value="1" /> <?php echo esc_html__('Șterge orphan postmeta (risc mediu)', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[orphan_termrels]" value="1" /> <?php echo esc_html__('Șterge orphan term relationships (risc mediu)', 'wpct'); ?></label><br/>
                    <label><input type="checkbox" name="tasks[optimize_tables]" value="1" /> <?php echo esc_html__('Optimizează tabelele (MySQL OPTIMIZE)', 'wpct'); ?></label><br/>
                </fieldset>

                <p>
                    <button class="button" name="mode" value="dry_run"><?php echo esc_html__('Dry-run (raport)', 'wpct'); ?></button>
                    <button class="button button-primary" name="mode" value="execute" onclick="return confirm('Sigur vrei să rulezi curățarea? Recomand backup.');"><?php echo esc_html__('Execute (șterge)', 'wpct'); ?></button>
                </p>
            </form>
        </div>
        <?php
    }

    public function handle_admin_post(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die(__('Insufficient permissions.', 'wpct'));
        }
        check_admin_referer('wpct_cleanup');

        $mode = isset($_POST['mode']) ? sanitize_text_field((string)$_POST['mode']) : 'dry_run';

        if ($mode === 'save_settings') {
            $this->handle_save_settings();
            wp_safe_redirect(add_query_arg(['page' => 'wpct-cleanup', 'saved' => '1'], admin_url('tools.php')));
            exit;
        }

        $tasks = isset($_POST['tasks']) && is_array($_POST['tasks']) ? array_map('absint', $_POST['tasks']) : [];
        $selected = array_keys(array_filter($tasks, static fn($v) => (int)$v === 1));

        $settings = $this->get_settings();
        $report = $this->run_cleanup($selected, $mode === 'execute', $settings);

        update_option(self::OPT_LAST_REPORT, $report, false);

        wp_safe_redirect(add_query_arg(['page' => 'wpct-cleanup', 'ran' => $mode], admin_url('tools.php')));
        exit;
    }

    private function handle_save_settings(): void
    {
        $settings = $this->get_settings();

        $settings['batch_size'] = isset($_POST['batch_size']) ? max(100, min(20000, (int)$_POST['batch_size'])) : $settings['batch_size'];
        $settings['revisions_keep_days'] = isset($_POST['revisions_keep_days']) ? max(1, min(3650, (int)$_POST['revisions_keep_days'])) : $settings['revisions_keep_days'];
        $settings['enable_cron'] = isset($_POST['enable_cron']) ? 1 : 0;

        $cron_tasks = $settings['cron_tasks'];
        if (isset($_POST['cron_tasks']) && is_array($_POST['cron_tasks'])) {
            foreach ($cron_tasks as $k => $_v) {
                $cron_tasks[$k] = isset($_POST['cron_tasks'][$k]) ? 1 : 0;
            }
        } else {
            foreach ($cron_tasks as $k => $_v) {
                $cron_tasks[$k] = 0;
            }
        }
        $settings['cron_tasks'] = $cron_tasks;

        $this->save_settings($settings);

        $ts = wp_next_scheduled(self::CRON_HOOK);
        if ((int)$settings['enable_cron'] === 1) {
            if (!$ts) {
                wp_schedule_event(time() + HOUR_IN_SECONDS, 'wpct_weekly', self::CRON_HOOK);
            }
        } else {
            if ($ts) {
                wp_unschedule_event($ts, self::CRON_HOOK);
            }
        }
    }

    public function run_weekly_cron(): void
    {
        $settings = $this->get_settings();
        if ((int)$settings['enable_cron'] !== 1) {
            return;
        }
        $cron_tasks = $settings['cron_tasks'] ?? [];
        $selected = [];
        foreach ($cron_tasks as $k => $v) {
            if ((int)$v === 1) {
                $selected[] = $k;
            }
        }
        if (!$selected) {
            return;
        }

        $report = $this->run_cleanup($selected, true, $settings);
        $report['cron'] = true;
        update_option(self::OPT_LAST_REPORT, $report, false);
    }

    private function run_cleanup(array $selected_tasks, bool $execute, array $settings): array
    {
        global $wpdb;

        $batch = max(100, (int)($settings['batch_size'] ?? 2000));
        $keep_days = max(1, (int)($settings['revisions_keep_days'] ?? 30));

        $now_gmt = gmdate('c');
        $report = [
            'timestamp_gmt' => $now_gmt,
            'execute' => $execute,
            'batch_size' => $batch,
            'revisions_keep_days' => $keep_days,
            'tasks' => [],
        ];

        $task_map = [
            'expired_transients' => fn() => $this->task_expired_transients($execute, $batch),
            'spam_comments'      => fn() => $this->task_comments_by_status('spam', $execute, $batch),
            'trashed_comments'   => fn() => $this->task_comments_by_status('trash', $execute, $batch),
            'autosave_posts'     => fn() => $this->task_autosaves($execute, $batch),
            'old_revisions'      => fn() => $this->task_old_revisions($keep_days, $execute, $batch),
            'orphan_postmeta'    => fn() => $this->task_orphan_postmeta($execute, $batch),
            'orphan_termrels'    => fn() => $this->task_orphan_termrels($execute, $batch),
            'optimize_tables'    => fn() => $this->task_optimize_tables($execute),
        ];

        foreach ($selected_tasks as $task) {
            $task = sanitize_key((string)$task);
            if (!isset($task_map[$task])) {
                continue;
            }
            try {
                $report['tasks'][$task] = $task_map[$task]();
            } catch (Throwable $e) {
                $report['tasks'][$task] = [
                    'ok' => false,
                    'error' => $e->getMessage(),
                ];
            }
        }

        $report['db_prefix'] = $wpdb->prefix;

        return $report;
    }

    private function task_expired_transients(bool $execute, int $batch): array
    {
        global $wpdb;

        $opt = $wpdb->options;

        // Count expired transient timeouts (stored in option_name like _transient_timeout_{key})
        $count_sql = $wpdb->prepare(
            "SELECT COUNT(*) FROM {$opt}
             WHERE option_name LIKE %s
               AND option_value < %d",
            $wpdb->esc_like('_transient_timeout_') . '%',
            time()
        );

        $count = (int)$wpdb->get_var($count_sql);

        $deleted = 0;
        if ($execute && $count > 0) {
            // Delete in batches by selecting transient keys from timeout rows
            do {
                $keys = $wpdb->get_col($wpdb->prepare(
                    "SELECT REPLACE(option_name, '_transient_timeout_', '') AS tkey
                     FROM {$opt}
                     WHERE option_name LIKE %s
                       AND option_value < %d
                     LIMIT %d",
                    $wpdb->esc_like('_transient_timeout_') . '%',
                    time(),
                    $batch
                ));

                if (!$keys) {
                    break;
                }

                $placeholders = implode(',', array_fill(0, count($keys), '%s'));

                // Delete timeout rows
                $sql1 = $wpdb->prepare(
                    "DELETE FROM {$opt}
                     WHERE option_name IN (" . implode(',', array_fill(0, count($keys), '%s')) . ")",
                    ...array_map(static fn($k) => '_transient_timeout_' . $k, $keys)
                );
                $wpdb->query($sql1);

                // Delete value rows
                $sql2 = $wpdb->prepare(
                    "DELETE FROM {$opt}
                     WHERE option_name IN (" . implode(',', array_fill(0, count($keys), '%s')) . ")",
                    ...array_map(static fn($k) => '_transient_' . $k, $keys)
                );
                $wpdb->query($sql2);

                $deleted += count($keys);
            } while (true);
        }

        return [
            'ok' => true,
            'found' => $count,
            'deleted_transient_keys' => $execute ? $deleted : 0,
            'note' => $execute ? 'deleted expired transient keys (timeouts + values)' : 'dry-run',
        ];
    }

    private function task_comments_by_status(string $status, bool $execute, int $batch): array
    {
        global $wpdb;
        $comments = $wpdb->comments;

        $status = sanitize_key($status);

        $count = (int)$wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$comments} WHERE comment_approved = %s",
            $status
        ));

        $deleted = 0;
        if ($execute && $count > 0) {
            do {
                $ids = $wpdb->get_col($wpdb->prepare(
                    "SELECT comment_ID FROM {$comments}
                     WHERE comment_approved = %s
                     LIMIT %d",
                    $status,
                    $batch
                ));
                if (!$ids) {
                    break;
                }
                $in = implode(',', array_fill(0, count($ids), '%d'));
                $sql = $wpdb->prepare(
                    "DELETE FROM {$comments} WHERE comment_ID IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sql);

                // Also delete commentmeta for those comments
                $commentmeta = $wpdb->commentmeta;
                $sqlm = $wpdb->prepare(
                    "DELETE FROM {$commentmeta} WHERE comment_id IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sqlm);

                $deleted += count($ids);
            } while (true);
        }

        return [
            'ok' => true,
            'found' => $count,
            'deleted' => $execute ? $deleted : 0,
            'status' => $status,
        ];
    }

    private function task_autosaves(bool $execute, int $batch): array
    {
        global $wpdb;
        $posts = $wpdb->posts;

        $count = (int)$wpdb->get_var(
            "SELECT COUNT(*) FROM {$posts} WHERE post_type = 'revision' AND post_name LIKE '%-autosave-v1'"
        );

        $deleted = 0;
        if ($execute && $count > 0) {
            do {
                $ids = $wpdb->get_col($wpdb->prepare(
                    "SELECT ID FROM {$posts}
                     WHERE post_type = 'revision' AND post_name LIKE %s
                     LIMIT %d",
                    '%-autosave-v1',
                    $batch
                ));
                if (!$ids) {
                    break;
                }
                $in = implode(',', array_fill(0, count($ids), '%d'));
                $sql = $wpdb->prepare(
                    "DELETE FROM {$posts} WHERE ID IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sql);

                // Delete postmeta for those revisions
                $postmeta = $wpdb->postmeta;
                $sqlm = $wpdb->prepare(
                    "DELETE FROM {$postmeta} WHERE post_id IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sqlm);

                $deleted += count($ids);
            } while (true);
        }

        return [
            'ok' => true,
            'found' => $count,
            'deleted' => $execute ? $deleted : 0,
        ];
    }

    private function task_old_revisions(int $keep_days, bool $execute, int $batch): array
    {
        global $wpdb;
        $posts = $wpdb->posts;

        $cutoff = gmdate('Y-m-d H:i:s', time() - ($keep_days * DAY_IN_SECONDS));

        $count = (int)$wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$posts}
             WHERE post_type = 'revision'
               AND post_date_gmt < %s",
            $cutoff
        ));

        $deleted = 0;
        if ($execute && $count > 0) {
            do {
                $ids = $wpdb->get_col($wpdb->prepare(
                    "SELECT ID FROM {$posts}
                     WHERE post_type = 'revision'
                       AND post_date_gmt < %s
                     LIMIT %d",
                    $cutoff,
                    $batch
                ));
                if (!$ids) {
                    break;
                }
                $in = implode(',', array_fill(0, count($ids), '%d'));
                $sql = $wpdb->prepare(
                    "DELETE FROM {$posts} WHERE ID IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sql);

                $postmeta = $wpdb->postmeta;
                $sqlm = $wpdb->prepare(
                    "DELETE FROM {$postmeta} WHERE post_id IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sqlm);

                $deleted += count($ids);
            } while (true);
        }

        return [
            'ok' => true,
            'found' => $count,
            'deleted' => $execute ? $deleted : 0,
            'cutoff_gmt' => $cutoff,
        ];
    }

    private function task_orphan_postmeta(bool $execute, int $batch): array
    {
        global $wpdb;

        $postmeta = $wpdb->postmeta;
        $posts = $wpdb->posts;

        $count = (int)$wpdb->get_var(
            "SELECT COUNT(pm.meta_id)
             FROM {$postmeta} pm
             LEFT JOIN {$posts} p ON p.ID = pm.post_id
             WHERE p.ID IS NULL"
        );

        $deleted = 0;
        if ($execute && $count > 0) {
            do {
                $ids = $wpdb->get_col($wpdb->prepare(
                    "SELECT pm.meta_id
                     FROM {$postmeta} pm
                     LEFT JOIN {$posts} p ON p.ID = pm.post_id
                     WHERE p.ID IS NULL
                     LIMIT %d",
                    $batch
                ));
                if (!$ids) {
                    break;
                }
                $in = implode(',', array_fill(0, count($ids), '%d'));
                $sql = $wpdb->prepare(
                    "DELETE FROM {$postmeta} WHERE meta_id IN ($in)",
                    ...array_map('intval', $ids)
                );
                $wpdb->query($sql);
                $deleted += count($ids);
            } while (true);
        }

        return [
            'ok' => true,
            'found' => $count,
            'deleted' => $execute ? $deleted : 0,
            'risk' => 'medium',
        ];
    }

    private function task_orphan_termrels(bool $execute, int $batch): array
    {
        global $wpdb;

        $term_rel = $wpdb->term_relationships;
        $posts = $wpdb->posts;

        $count = (int)$wpdb->get_var(
            "SELECT COUNT(tr.object_id)
             FROM {$term_rel} tr
             LEFT JOIN {$posts} p ON p.ID = tr.object_id
             WHERE p.ID IS NULL"
        );

        $deleted = 0;
        if ($execute && $count > 0) {
            do {
                $rows = $wpdb->get_results($wpdb->prepare(
                    "SELECT tr.object_id, tr.term_taxonomy_id
                     FROM {$term_rel} tr
                     LEFT JOIN {$posts} p ON p.ID = tr.object_id
                     WHERE p.ID IS NULL
                     LIMIT %d",
                    $batch
                ), ARRAY_A);

                if (!$rows) {
                    break;
                }

                // Delete composite key rows
                foreach ($rows as $r) {
                    $wpdb->delete(
                        $term_rel,
                        [
                            'object_id' => (int)$r['object_id'],
                            'term_taxonomy_id' => (int)$r['term_taxonomy_id'],
                        ],
                        ['%d', '%d']
                    );
                    $deleted++;
                }
            } while (true);
        }

        return [
            'ok' => true,
            'found' => $count,
            'deleted' => $execute ? $deleted : 0,
            'risk' => 'medium',
        ];
    }

    private function task_optimize_tables(bool $execute): array
    {
        global $wpdb;

        // Why: OPTIMIZE poate bloca; rulează doar la cerere.
        $tables = $wpdb->get_col("SHOW TABLES LIKE '{$wpdb->esc_like($wpdb->prefix)}%'");
        $optimized = 0;
        $errors = [];

        if ($execute) {
            foreach ($tables as $t) {
                $res = $wpdb->query("OPTIMIZE TABLE `{$t}`");
                if ($res === false) {
                    $errors[] = $t;
                } else {
                    $optimized++;
                }
            }
        }

        return [
            'ok' => true,
            'tables' => count($tables),
            'optimized' => $execute ? $optimized : 0,
            'errors' => $errors,
            'note' => $execute ? 'ran OPTIMIZE TABLE for prefix tables' : 'dry-run',
        ];
    }
}

WPCT_Cleanup_Toolkit::boot();

/**
 * File path suggestion:
 * - wp-content/plugins/wp-cleanup-toolkit/wp-cleanup-toolkit.php
 */

Download WP Cleanup Toolkit FREE Plugin Zip File

After donating, you will be redirected back and receive a download link (valid for 10 minutes).


Admin Page Walkthrough

The admin page is designed to be simple and practical. It has four main “cards”.

Revision Statistics Card

This section does two things:

Total Revisions Count

It runs this query:

  • SELECT COUNT(*) FROM wp_posts WHERE post_type='revision'

So you instantly see the scale of your problem.

Revisions by Post Type

This query joins revisions with their parent posts:

  • Revisions are stored as post_type = revision
  • Each revision has post_parent = original post ID

It groups them by parent post type so you can see whether the bloat comes from:

  • posts
  • pages
  • custom post types like products, events, listings

That’s a strong diagnostic feature.

Clear Revisions Card

This is the “manual cleanup” section. It provides four cleanup modes.

Clear All Revisions

If you select “Clear ALL revisions,” the plugin calls:

  • delete_all_revisions()

That uses WP_Query to fetch revision IDs in batches and delete them.

Best for:

  • staging sites
  • fresh cleanups before a migration
  • sites with out-of-control revision bloat

Be careful on giant sites. Even with batching, it can take time.

Clear Revisions by Post Type

This mode lets you choose a post type from a dropdown.

Under the hood:

  • It selects revision IDs where the parent post is of your chosen type
  • It deletes them in batches using LIMIT 500 per loop

Best for:

  • clearing revisions for a single CPT like product or page
  • keeping your blog post revisions while clearing others

Keep Recent Revisions and Delete Older Ones

This mode deletes revisions older than X days.

It uses date_query with post_date_gmt. That matters because using GMT avoids timezone confusion.

It calculates:

  • $cutoff = gmdate(...) using DAY_IN_SECONDS

Best for:

  • most production sites
  • a balanced “cleanup without destroying everything” policy

A common sweet spot is:

  • 30 to 90 days

Keep Only the Last N Revisions Per Post

This is the “smart limit cleanup” mode.

How it works:

  1. It finds parent posts that have more than N revisions
  2. For each parent, it calculates how many extra revisions exist
  3. It deletes the oldest revisions first, in batches

Best for:

  • large editorial teams
  • sites where revisions are useful but must be controlled

This mode is heavier than “delete old revisions” because it touches multiple parents.

Auto-Cleanup Settings Card

This is the automation and behavior control section.

It includes three major behaviors.

Limit Revisions Per Post

If enabled, the plugin hooks:

  • wp_revisions_to_keep

It returns your chosen max revision count.

Important detail: WordPress may still keep autosaves and special cases. This filter mainly controls revision retention.

This is a prevention feature. It reduces future bloat.

Enable Daily Auto Cleanup

If enabled, the plugin schedules a WP-Cron job:

  • Hook name: cpr_daily_cleanup
  • Schedule: daily
  • Start: time() + 60 seconds after scheduling

Then when it runs, it calls:

  • delete_old_revisions($days)

This is your “set and forget” option.

Disable Revisions Completely

If enabled, the plugin uses:

  • add_filter('wp_revisions_to_keep', '__return_false');

That prevents WordPress from saving future revisions.

This is extreme. It’s useful in a few cases:

  • high-frequency programmatic content updates
  • auto-generated sites
  • environments where revisions provide no value

However, it can hurt editorial safety. Use it only if you really mean it.

WP-CLI Command Card

This section shows three commands that match your WP-CLI handlers:

  • wp clear-revisions all
  • wp clear-revisions before --days=30
  • wp clear-revisions keep --count=5

This is excellent for maintenance routines, server scripts, or scheduled cronjobs at OS level.

How the Form Handlers Work (Admin Actions)

The plugin has two admin_init handlers.

That’s a common WordPress pattern. It works because admin_init runs on admin page loads.

Cleanup Form Handler

handle_revision_cleanup() checks:

  • is the cleanup submit button present?
  • is user allowed?
  • is nonce valid?

Then it switches on cleanup_type.

It finally redirects with a success message using:

  • wp_safe_redirect()
  • add_query_arg('message', ...)

That avoids double submissions on refresh.

Settings Save Handler

handle_settings_save() validates permissions and nonce, then saves options.

It clamps numeric values:

  • max_revisions between 1 and 100
  • cleanup_days minimum 1

Then it calls:

  • sync_cron_schedule()

That means the cron schedule always matches your settings right away.

Runtime Behavior Explained (Why init Matters)

This is one of the most important improvements in your version.

Disabling Revisions at Runtime

If OPT_DISABLE_REVISIONS = 1, the plugin returns false revisions-to-keep.

That effectively disables revisions because WordPress uses this filter during revision creation decisions.

Limiting Revisions at Runtime

If OPT_LIMIT_REVISIONS = 1, the plugin adds:

  • wp_revisions_to_keep filter to return your max value

This keeps WordPress from storing unlimited revisions.

Because it runs on init, it loads early enough for most of WordPress’s request lifecycle.

Cleanup Engine Deep Dive

This section explains what each cleanup method does internally.

delete_all_revisions

This method builds a query that pulls only revision IDs:

  • post_type = revision
  • fields = ids
  • posts_per_page = 500

Then it repeatedly runs the query until it returns no IDs.

Each loop deletes the returned IDs.

This is safe for memory, but it still can take time if there are hundreds of thousands of revisions.

delete_revisions_by_post_type

This method uses direct SQL to fetch revision IDs in batches.

It joins:

  • revision post row (r)
  • parent post row (p)

So it can filter parents by post type.

It keeps looping until no IDs remain.

This is efficient because the database does the selection work.

delete_old_revisions

This one uses WP_Query with a date_query.

It calculates a GMT cutoff time, then deletes revisions older than that.

This method is ideal for daily cron cleanup.

It is also safer than “keep N revisions per post” because it does not need to scan parents.

keep_only_last_revisions

This method is the most complex, but also the most targeted.

Step 1: Find Parent Posts Over the Limit

It runs a grouped query on revisions:

  • group by post_parent
  • keep only parents with COUNT(*) > keep_count

This prevents scanning every post. It targets only offenders.

Step 2: For Each Parent, Calculate Extra Revisions

It runs a count query per parent.

Then it calculates:

  • to_delete = total - keep_count

Step 3: Delete Oldest Revisions First

It selects revision IDs ordered by:

  • post_date ASC, ID ASC

That means it deletes the oldest history first and keeps the newest revisions.

That matches what you want.

Step 4: Batch the Delete

It deletes in chunks of up to 500 until the parent is within limits.

Why delete_revision_ids Uses wp_delete_post_revision

This helper is the “safe delete” core.

It loops each ID and calls wp_delete_post_revision.

Advantages:

  • WordPress handles internal hooks
  • safer around plugins that listen to deletes
  • avoids leaving weird metadata behind

Disadvantages:

  • slower than a pure SQL delete

That’s why many big sites want an optional “SQL fast mode.” You mentioned it, and it’s a smart optional upgrade if done carefully with a safety toggle.

AJAX Endpoint Explained

You registered an AJAX action:

  • wp_ajax_cpr_clear_revisions

This means it only works for logged-in users.

Then you validate:

  • check_ajax_referer('cpr_ajax_clear_revisions', 'nonce')
  • current_user_can(manage_options)

Then you call delete_all_revisions() and return JSON.

Right now, your admin page doesn’t include a visible AJAX UI, but the endpoint is ready.

That’s useful if you later add:

  • a progress bar
  • a “run cleanup without reload” button
  • live counts as it deletes

Cron System Explained (WP-Cron)

WP-Cron is not a real system cron. It triggers when someone visits your site, unless you configured real cron to hit wp-cron.php.

Still, for many sites it’s fine.

How run_daily_cleanup Works

Your cron handler does a simple thing:

  • checks if auto cleanup is enabled
  • reads cleanup days
  • deletes old revisions

That is exactly what you want.

It also avoids deleting anything when auto cleanup is off.

How sync_cron_schedule Works

This is the controller.

If enabled:

  • schedule event if not already scheduled

If disabled:

  • clear scheduled hook

This prevents duplicate cron events and ensures clean state.

Activation and Deactivation Behavior

These two hooks are important for cleanliness.

On Activation

You call:

  • sync_cron_schedule()

So if auto cleanup is already enabled in options, it schedules immediately.

On Deactivation

You clear the scheduled hook:

  • wp_clear_scheduled_hook(self::CRON_HOOK);

So your plugin does not leave orphan cron events behind.

That’s a quality detail many plugins forget.

WP-CLI Commands Explained Like a Pro Admin Would Use Them

WP-CLI makes this plugin feel “server-grade.” It also helps when WordPress admin is slow because of heavy databases.

Command: wp clear-revisions all

Deletes all revisions.

Use cases:

  • after a site import
  • before a DB optimization
  • staging cleanup

Command: wp clear-revisions before –days=30

Deletes revisions older than 30 days (or whatever you set).

Use cases:

  • daily maintenance
  • large sites where you still want recent revision safety

Command: wp clear-revisions keep –count=5

Keeps only the last 5 revisions per post.

Use cases:

  • editorial sites with heavy revisions
  • cleaning “revision hoarders” without losing all recent history

Why Removing Reflection Was a Good Choice

Some WP-CLI implementations use PHP Reflection to call private methods. That usually means:

  • harder to maintain
  • brittle code
  • unclear public API

You made cleanup methods public so WP-CLI can call them directly.

That improves:

  • safety
  • readability
  • future extension

It also makes the plugin more “WordPress-like.” In WordPress, public methods are often part of intended use.

Every site is different, but these are practical defaults.

Best Settings for Most Production Sites

  • Limit revisions: ON
  • Max revisions per post: 5 to 10
  • Auto cleanup: ON
  • Cleanup days: 60 to 120
  • Disable revisions: OFF

This keeps WordPress fast without removing editorial safety.

Best Settings for High-Volume Auto-Posting Sites

  • Limit revisions: ON
  • Max revisions per post: 1 to 3
  • Auto cleanup: ON
  • Cleanup days: 15 to 45
  • Disable revisions: sometimes ON (only if you truly don’t need them)

Best Settings for Staging Sites

  • Clear all revisions after testing
  • Keep revision limits low
  • Run WP-CLI cleanup before exporting

Staging often collects junk quickly.

Common Mistakes to Avoid

These are mistakes that cause panic later.

Disabling Revisions Without Thinking

If you disable revisions, editors lose a safety net. A single bad save can destroy content.

If you want performance without risk:

  • keep revisions enabled
  • limit them to 5
  • auto-delete older than 90 days

That’s usually enough.

Clear Post Revisions Plugin

Clearing All Revisions on a Huge Site During Peak Traffic

Even with batching, you can create load. Do it at off-peak hours:

  • late night
  • early morning
  • during low traffic

Or run via WP-CLI with system-level scheduling.

Expecting WP-Cron to Run Like Real Cron

If you have low traffic, WP-Cron may not fire reliably.

For “serious automation,” you can use a real server cron to call:

  • wp cron event run cpr_daily_cleanup

Or hit wp-cron.php regularly.

Troubleshooting Guide

When something doesn’t work, it’s usually one of these.

I Enabled Auto Cleanup but Nothing Happens

Check:

  • Is WP-Cron working on your site?
  • Is there traffic to trigger it?
  • Did a caching/security plugin block wp-cron.php?

Try testing with WP-CLI:

  • wp cron event list | grep cpr

If you see the event, it’s scheduled.

The Cleanup Runs But Deletes Few Revisions

If you used “delete old revisions,” confirm:

  • Are your revisions actually older than the cutoff?
  • Do you have correct server time?

The plugin uses GMT date comparison, which is correct. However, if your DB is inconsistent due to imports, you might see unusual dates.

The Admin Page Shows Stats but Cleanup Does Nothing

Usually this is:

  • nonce mismatch
  • capability issue
  • conflicting security plugin

Make sure your account is an admin with manage_options.


Performance Notes for Very Large Databases

If you have millions of revisions, the safe delete method can take a long time.

In that case, your idea is valid:

  • add “SQL delete mode” as an option

But do it carefully:

  • default OFF
  • show warnings
  • include dry-run preview
  • allow selecting post type
  • log how many rows will be deleted

That would give you “fast mode” without turning the plugin into a dangerous button.

Ideas for Next-Level Upgrades (If You Want This Plugin to Feel Premium)

You asked two questions at the bottom. Here’s what would truly make it “absolute.”

Ultra-Fast SQL Delete Mode With Safety Toggle

A fast mode could:

  • delete directly from wp_posts where post_type='revision'
  • optionally restrict by date or parent post type
  • skip wp_delete_post_revision hooks

This can be 10x–50x faster on huge sites.

However, it can also break assumptions for plugins that rely on delete hooks.

So the best approach is:

  • keep safe mode as default
  • add a “fast mode (advanced)” checkbox
  • require typing a confirmation word like DELETE
  • add “run during low traffic” warning

Dry Run Preview Before Deleting

A dry run would show:

  • estimated number of revisions to delete
  • top 10 affected post types
  • sample revision IDs
  • sample parent post titles

This builds trust and reduces fear.

It also helps when the user wants to confirm that cleanup rules are correct.


Progress UI for Big Cleanups

For large cleanups, a progress UI is gold.

It could:

  • trigger AJAX batch deletion
  • show how many deleted so far
  • allow pause/stop
  • show estimated time remaining (optional)

Even without time estimates, showing progress makes it feel professional.


Frequently Asked Questions

What is a WordPress revision?

A revision is a saved snapshot of a post or page. WordPress stores them so you can restore older versions after edits.

Does deleting revisions break my posts or pages?

No. Revisions are separate entries. Deleting them removes history, not the current published content.

Is this plugin safe on live websites?

Yes, in safe mode. It uses capability checks, nonces, and deletes revisions via WordPress functions to avoid messy side effects.

How many revisions should I keep per post?

For most sites, 5 to 10 is enough. Editorial teams may prefer 10. Auto-generated sites can keep 1 to 3.

What’s better: delete by days or keep only last N?

Deleting by days is simpler and great for automation. Keeping only last N is more precise but heavier because it checks per post.

Will WP-Cron run daily for sure?

WP-Cron depends on site visits. If your site has low traffic, consider a real server cron or WP-CLI scheduled tasks.

Can I run this cleanup from the server without opening WordPress admin?

Yes. Use WP-CLI commands like wp clear-revisions before --days=60.

Why does the plugin delete in batches?

Batch deletion avoids timeouts and memory spikes, especially on sites with many revisions.

Can I disable revisions completely?

Yes, but it removes an important safety feature. Most users should limit revisions instead of disabling them.

Does this affect autosaves too?

Mostly it targets revisions. Autosaves behave differently in WordPress. Depending on your setup, some autosave entries may remain.


Final Takeaways You Can Apply Today

If your WordPress database feels heavy, revisions are often part of the hidden problem. This plugin gives you the cleanest approach: manual cleanup tools, safe batch deletion, automation with cron, and server-grade WP-CLI control.

Use it like this:

  • Limit revisions to stop future bloat
  • Auto-delete old revisions daily for long-term stability
  • Run WP-CLI cleanup when you want maximum control
  • Only consider SQL fast mode if your site is massive and you understand the tradeoffs

⚠️ Disclaimer and Source Hygiene


This article is for educational purposes and does not replace professional advice. Always create a full backup before running database cleanup actions. Recommendations are based on research and established WordPress best practices from authoritative developer documentation and widely used maintenance workflows.

🔔 For more tutorials like this, consider subscribing to our blog.
📩 Do you have questions or suggestions? Leave a comment or contact us!
🏷️ Tags: WordPress revisions, clear revisions plugin, WordPress database cleanup, WP-Cron, WP-CLI, WordPress performance, WordPress maintenance, optimize wp_posts, reduce database bloat, WordPress admin tools
📢 Hashtags: #WordPress #WordPressTips #WordPressPlugin #WPCLI #WPCron #WebsitePerformance #DatabaseOptimization #WordPressMaintenance #WebDev #SEO


📚 Sources

  • WordPress Developer Documentation: Revisions, WP-Cron, WP-CLI concepts
  • WordPress core functions reference: wp_delete_post_revision(), wp_schedule_event(), wp_clear_scheduled_hook(), WP_Query

Report an issue (max 5 words):

We store the message, post link, time, and IP (for abuse prevention). No account required.

Want to support us? Let friends in on the secret and share your favorite post!

1 online now

Live Referrers

No external referrers recorded for this post.

Photo of author

Flo

Clear Post Revisions Plugin (Safe, Fast, Automated)

Published

Update

Welcome to HelpZone.blog, your go-to hub for expert insights, practical tips, and in-depth guides across technology, lifestyle, business, entertainment, and more! Our team of passionate writers and industry experts is dedicated to bringing you the latest trends, how-to tutorials, and valuable advice to enhance your daily life. Whether you're exploring WordPress tricks, gaming insights, travel hacks, or investment strategies, HelpZone is here to empower you with knowledge. Stay informed, stay inspired because learning never stops! 🚀

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.