Automatically Delete Trashed Posts After X Days (WordPress PHP)

⏲️ Estimated reading time: 12 min

Table of Contents

Want your WordPress Trash to clean itself automatically? This guide shows two safe options: a simple core setting that empties Trash after X days, plus a more advanced MU-plugin that can target specific post types, run on a schedule, and log deletions for peace of mind.

  • Why Auto-Deleting Trashed Posts Matters
  • How WordPress Trash Works Behind the Scenes
  • What Happens If You Never Empty the Trash
  • When You Should Not Auto-Delete Trash
  • The Two Best Ways to Auto-Delete Trashed Posts
  • Option 1: Use WordPress Core Setting (Fastest and Safest)
  • Option 2: Use a Custom PHP Cleaner (More Control)
  • Option 1: Set WordPress to Empty Trash After X Days
  • The Easiest Solution: EMPTY_TRASH_DAYS
  • Where to Add It
  • Example: Empty Trash After 7 Days
// wp-config.php (best place)
define('EMPTY_TRASH_DAYS', 7);
  • What This Method Deletes
  • Pros of Using EMPTY_TRASH_DAYS
  • Cons of Using EMPTY_TRASH_DAYS
  • Common Mistakes With EMPTY_TRASH_DAYS
  • Option 2: Advanced MU-Plugin Cleaner (Target Posts Only)
  • Why Use an MU-Plugin for This
  • What This MU-Plugin Will Do
  • Safety Principles This Code Follows
  • The Complete PHP Code
<?php
/**
 * Plugin Name: HZ Auto Delete Trashed Posts After X Days (MU)
 * Description: Permanently deletes trashed posts older than X days using a safe WP-Cron job. Supports post type filtering and logging.
 * Author: Tokyo Blade
 * Version: 1.0.0
 *
 * Install as MU-plugin:
 * /wp-content/mu-plugins/hz-auto-delete-trashed-posts.php
 */

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

class HZ_Auto_Delete_Trashed_Posts {

    const CRON_HOOK = 'hz_delete_trashed_posts_cron_hook';

    /**
     * Configuration
     * Change these values to match your needs.
     */
    public static function config(): array {
        return [
            // Delete items that have been in trash for at least this many days.
            'days' => 14,

            // Which post types should be cleaned?
            // Use ['any'] to include all public post types (still only deletes those in trash).
            'post_types' => ['post', 'page'],

            // Limit deletions per run to avoid timeouts on large sites.
            'batch_size' => 50,

            // If true, the plugin will NOT delete anything; it will only log what it would delete.
            'dry_run' => false,

            // Enable simple logging to wp-content/debug.log (requires WP_DEBUG_LOG true)
            'log' => true,
        ];
    }

    public static function init(): void {
        add_action('init', [__CLASS__, 'maybe_schedule']);
        add_action(self::CRON_HOOK, [__CLASS__, 'run_cleanup']);
    }

    public static function maybe_schedule(): void {
        if (!wp_next_scheduled(self::CRON_HOOK)) {
            // Schedule daily; WordPress will run it when someone visits the site (WP-Cron behavior).
            wp_schedule_event(time() + 300, 'daily', self::CRON_HOOK);
        }
    }

    private static function log(string $message): void {
        $cfg = self::config();
        if (empty($cfg['log'])) {
            return;
        }
        if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
            error_log('[HZ Trash Cleaner] ' . $message);
        }
    }

    /**
     * Determine when a post was moved to trash.
     * WordPress stores trash metadata in _wp_trash_meta, including a unix timestamp in 'time'.
     */
    private static function trashed_timestamp(int $post_id): ?int {
        $meta = get_post_meta($post_id, '_wp_trash_meta', true);

        // _wp_trash_meta is usually an array like ['status' => 'publish', 'time' => 1700000000]
        if (is_array($meta) && isset($meta['time'])) {
            $t = (int) $meta['time'];
            return $t > 0 ? $t : null;
        }

        // Fallback: use modified time if trash meta is missing for some reason.
        $modified_gmt = get_post_field('post_modified_gmt', $post_id);
        if (!empty($modified_gmt) && $modified_gmt !== '0000-00-00 00:00:00') {
            $ts = strtotime($modified_gmt . ' GMT');
            return $ts ?: null;
        }

        return null;
    }

    private static function resolve_post_types(array $requested): array {
        if (in_array('any', $requested, true)) {
            $types = get_post_types(['public' => true], 'names');
            // Keep it safe: ignore attachments here unless you explicitly want them.
            unset($types['attachment']);
            return array_values($types);
        }
        return array_values(array_filter($requested));
    }

    public static function run_cleanup(): void {
        $cfg = self::config();

        $days = max(1, (int) ($cfg['days'] ?? 14));
        $batch = max(1, min(500, (int) ($cfg['batch_size'] ?? 50)));
        $dry_run = !empty($cfg['dry_run']);

        $post_types = self::resolve_post_types((array) ($cfg['post_types'] ?? ['post', 'page']));

        // Cutoff timestamp: anything trashed before this moment should be deleted
        $cutoff = time() - ($days * DAY_IN_SECONDS);

        self::log("Cleanup started. days={$days}, batch_size={$batch}, dry_run=" . ($dry_run ? 'true' : 'false') . ", cutoff=" . gmdate('Y-m-d H:i:s', $cutoff) . " GMT");

        $q = new WP_Query([
            'post_type'              => $post_types,
            'post_status'            => 'trash',
            'posts_per_page'         => $batch,
            'fields'                 => 'ids',
            'orderby'                => 'date',
            'order'                  => 'ASC',
            'no_found_rows'          => true,
            'ignore_sticky_posts'    => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        if (empty($q->posts)) {
            self::log('No trashed posts found in this batch.');
            return;
        }

        $deleted = 0;
        $skipped = 0;

        foreach ($q->posts as $post_id) {
            $trashed_at = self::trashed_timestamp((int) $post_id);

            if (!$trashed_at) {
                $skipped++;
                self::log("Skipped post_id={$post_id} (cannot determine trash time).");
                continue;
            }

            if ($trashed_at > $cutoff) {
                $skipped++;
                self::log("Skipped post_id={$post_id} (trashed_at=" . gmdate('Y-m-d H:i:s', $trashed_at) . " GMT, still within {$days} days).");
                continue;
            }

            $title = get_the_title($post_id);
            $type  = get_post_type($post_id);

            if ($dry_run) {
                $deleted++;
                self::log("DRY RUN: Would delete post_id={$post_id}, type={$type}, title=\"" . $title . "\"");
                continue;
            }

            // wp_delete_post($id, true) => force delete (bypass trash)
            $result = wp_delete_post($post_id, true);

            if ($result) {
                $deleted++;
                self::log("Deleted post_id={$post_id}, type={$type}, title=\"" . $title . "\"");
            } else {
                $skipped++;
                self::log("Failed deleting post_id={$post_id} (wp_delete_post returned false).");
            }
        }

        self::log("Cleanup finished. deleted={$deleted}, skipped={$skipped}");
    }
}

HZ_Auto_Delete_Trashed_Posts::init();

What Each Part of the Code Does

Plugin Header and MU-Plugin Purpose

The header block at the top lets WordPress recognize the file as a plugin. Because we place it in mu-plugins, it loads automatically for every request, without needing activation from the admin panel.

That’s perfect for maintenance tasks like Trash cleanup because:

  • you won’t forget to activate it after migrations,
  • theme updates can’t remove it,
  • it runs consistently.

The Configuration Array

Inside config() you control everything in one place:

  • days: how long an item can stay in Trash before permanent deletion.
  • post_types: which content types to clean.
  • batch_size: how many to delete in a single run.
  • dry_run: safe testing mode (logs only).
  • log: sends messages to debug.log when enabled.

This design keeps your code clean and reduces “hunt and replace” edits across the file.


Scheduling With WP-Cron

maybe_schedule() checks if your cleanup event exists:

  • if not, it schedules a daily event.

WordPress WP-Cron runs when your site gets traffic. That means:

  • no traffic = no cron runs,
  • but most sites get enough visits for daily scheduling to work fine.

If your site is very low traffic, you can switch to a real server cron later. The plugin still works.


Finding “When It Was Trashed”

This is the most important part.

WordPress stores trash metadata in a post meta key called:

  • _wp_trash_meta

It usually contains:

  • the original status (publish/draft),
  • a timestamp called time (when the post moved to trash).

The function trashed_timestamp() reads that value, and if it fails, it uses a fallback:

  • post_modified_gmt

That fallback protects you from edge cases like:

  • older imports,
  • custom scripts that trashed items without writing meta,
  • unusual post types.

Querying Only Trashed Items

WP_Query is set to:

  • post_status => 'trash'
  • post_type => ['post','page'] (or whatever you set)
  • posts_per_page => batch_size

We also set performance flags:

  • no_found_rows => true
  • meta and term caches disabled for speed

The goal is simple:

  • do less work every day,
  • keep the site fast.

Cutoff Logic

We compute:

  • cutoff = now - (days * DAY_IN_SECONDS)

If the item was trashed before the cutoff:

  • we delete it permanently.

If not:

  • we skip it.

That is the exact behavior people usually mean by “delete trashed posts after X days”.


Permanent Deletion

This line does the real job:

  • wp_delete_post($post_id, true);

The true means:

  • force delete (bypass Trash completely)

So the post is removed from the database and can’t be restored from Trash anymore.

Logging and Dry Run Mode

If dry_run is enabled, the plugin logs:

  • what it would delete,
    but it never deletes anything.

This is how you should test on a real site:

  1. set dry_run => true
  2. wait for a cron run (or manually trigger WP-Cron)
  3. check the logs
  4. set dry_run => false

How to Install This on Your WordPress Site

Step 1: Create the MU-Plugins Folder

Go to:

  • wp-content/

If you don’t see mu-plugins, create it:

  • wp-content/mu-plugins/

Step 2: Create the Plugin File

Create:

  • wp-content/mu-plugins/hz-auto-delete-trashed-posts.php

Paste the code inside.

Step 3: Enable WordPress Debug Log (Optional but Recommended)

In wp-config.php:

define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);

Then check:

  • wp-content/debug.log

Step 4: Test Using Dry Run

Change:

  • 'dry_run' => true

Let it run once, confirm logs, then set:

  • 'dry_run' => false

How to Customize It for Your Site

Change the Number of Days

Set:

  • 'days' => 7 (or whatever)

Delete All Public Post Types

Use:

  • 'post_types' => ['any']

This includes public post types but excludes attachment by default for safety.

Include a Custom Post Type

Example:

  • 'post_types' => ['post', 'page', 'product']

Increase Batch Size Carefully

For large sites, increase slowly:

  • 'batch_size' => 100

If your hosting is weak, keep it smaller.


Pros and Contra

✅ Benefits of Auto-Deleting Trashed Posts

  • keeps your database cleaner over time
  • reduces clutter in the admin
  • prevents huge trash piles after bulk edits
  • lowers risk of restoring old junk by accident

⚠️ Downsides to Consider

  • once deleted, you can’t restore from Trash
  • a too-small “days” value can remove content sooner than expected
  • WP-Cron timing depends on traffic unless you use a real cron

Comparison Table: Core Setting vs MU-Plugin

FeatureEMPTY_TRASH_DAYSMU-Plugin Cleaner
Setup difficultyVery easyMedium
Controls post typesNoYes
LoggingNoYes
Dry run testingNoYes
Batch limitNoYes
Best forMost websitesPower users / big sites

Practical Recommendations

Best Settings for Most Blogs

  • Use EMPTY_TRASH_DAYS = 14 or 30
  • Keep it simple unless you need post type control

Best Settings for Busy Content Sites

  • Use MU-plugin
  • Set days to 7–21
  • Set batch size to 50–150
  • Enable dry run first

Best Settings for WooCommerce Sites

Be careful with custom post types like:

  • shop_order, product, shop_coupon

If you include them, double-check your workflow. Many stores don’t want automated deletions for orders, even in trash.


Frequently Asked Questions

How many days should I keep posts in Trash?

Most sites do well with 14–30 days. If you publish daily and delete often, 7–14 days can work. If you rarely delete content, keep 30 days.

Is EMPTY_TRASH_DAYS enough for most websites?

Yes. It’s the simplest and most stable option because WordPress supports it directly.

Will this delete media attachments too?

The MU-plugin does not include attachment when you use any. If you add attachment manually, it can permanently delete trashed media.

Can I restore posts after the MU-plugin deletes them?

No. It uses forced deletion. Once removed, you’d need a backup to restore.

Does WP-Cron run exactly daily?

Not always. WP-Cron runs when visitors load pages. On low-traffic sites, it may run late.

Can I run this with a real server cron instead of WP-Cron?

Yes. Many admins disable WP-Cron and use server cron to call wp-cron.php. The MU-plugin still works.

Will this break my site?

The approach is safe because it targets only post_status = trash and checks a trash timestamp cutoff before deletion.

Can I exclude specific posts from deletion?

This code doesn’t include exclusions by ID yet. If you want, you can add an “exclude IDs” array and skip them in the loop.

Can I clean only pages and not posts?

Yes. Set:

  • 'post_types' => ['page']

What if _wp_trash_meta is missing?

The plugin falls back to post_modified_gmt. If it can’t determine a timestamp, it skips the post to stay safe.

Automatically Delete Trashed Posts After X Days (WordPress PHP)

Auto-deleting Trash is one of those “small” maintenance tweaks that quietly keeps WordPress healthy. If you want the quickest win, set EMPTY_TRASH_DAYS and forget about it. If you want full control, the MU-plugin approach gives you scheduling, batching, and logging without adding a heavy plugin.


⚠️ Disclaimer and Source Hygiene


This article is for educational purposes and general site maintenance guidance. Always test changes on a staging site first and keep reliable backups. WordPress setups vary by theme, plugins, hosting, and caching layers. Recommendations here are based on practical development patterns and standard WordPress behavior.

🔔 For more tutorials like this, consider subscribing to our blog.
📩 Do you have questions or suggestions? Leave a comment or contact us!
🏷️ Tags: WordPress, WordPress PHP, MU-plugin, WP-Cron, Trash cleanup, Database optimization, WordPress maintenance, Delete trashed posts, wp-config, Site performance
📢 Hashtags: #WordPress #WordPressTips #WordPressPHP #WPDeveloper #MUPlugin #WPCron #WebsiteMaintenance #WPPerformance #BloggingTips #TechTutorial


📚 Sources and References

WordPress Developer Concepts Used
Practical Notes From Real-World WordPress Maintenance
Suggested Reading Inside WordPress Admin

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!

0 online now

Live Referrers

No external referrers recorded for this post.

Photo of author

Flo

Automatically Delete Trashed Posts After X Days (WordPress PHP)

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.