Prevent WordPress from automatically purging trash from custom post type

Preface

WordPress has a constant called EMPTY_TRASH_DAYS that it automatically sets to 30. What that means is that WordPress will automatically delete all posts, pages, custom post types, etc, that have been in the trash for 30 or more days.

1
2
3
4
5
6
7
8
// wp-includes/default-constants.php
 
function wp_functionality_constants( ) {
    ...
    if ( !defined( 'EMPTY_TRASH_DAYS' ) )
        define( 'EMPTY_TRASH_DAYS', 30 );
    ...
}
// wp-includes/default-constants.php

function wp_functionality_constants( ) {
	...
	if ( !defined( 'EMPTY_TRASH_DAYS' ) )
		define( 'EMPTY_TRASH_DAYS', 30 );
	...
}

Whenever you trash a post, WordPress creates a meta field called _wp_trash_meta_time containing the time it was trashed.

1
2
3
4
5
6
7
// wp-includes/post.php
 
function wp_trash_post($post_id = 0) {
    ...
    add_post_meta($post_id,'_wp_trash_meta_time', time());
    ...
}
// wp-includes/post.php

function wp_trash_post($post_id = 0) {
	...
	add_post_meta($post_id,'_wp_trash_meta_time', time());
	...
}

WordPress has a daily event scheduled…

1
2
3
4
// wp-admin/admin.php
 
if ( !wp_next_scheduled('wp_scheduled_delete') && !defined('WP_INSTALLING') )
    wp_schedule_event(time(), 'daily', 'wp_scheduled_delete');
// wp-admin/admin.php

if ( !wp_next_scheduled('wp_scheduled_delete') && !defined('WP_INSTALLING') )
	wp_schedule_event(time(), 'daily', 'wp_scheduled_delete');

…that will run a function called wp_scheduled_delete to permanently delete all expired posts.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// wp-includes/functions.php
 
function wp_scheduled_delete() {
    global $wpdb;
 
    $delete_timestamp = time() - ( DAY_IN_SECONDS * EMPTY_TRASH_DAYS );
 
    $posts_to_delete = $wpdb->get_results($wpdb->prepare("SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_trash_meta_time' AND meta_value < '%d'", $delete_timestamp), ARRAY_A);
 
    foreach ( (array) $posts_to_delete as $post ) {
        $post_id = (int) $post['post_id'];
        if ( !$post_id )
            continue;
 
        $del_post = get_post($post_id);
 
        if ( !$del_post || 'trash' != $del_post->post_status ) {
            delete_post_meta($post_id, '_wp_trash_meta_status');
            delete_post_meta($post_id, '_wp_trash_meta_time');
        } else {
            wp_delete_post($post_id);
        }
    }
    ...
}
// wp-includes/functions.php

function wp_scheduled_delete() {
	global $wpdb;

	$delete_timestamp = time() - ( DAY_IN_SECONDS * EMPTY_TRASH_DAYS );

	$posts_to_delete = $wpdb->get_results($wpdb->prepare("SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_trash_meta_time' AND meta_value < '%d'", $delete_timestamp), ARRAY_A);

	foreach ( (array) $posts_to_delete as $post ) {
		$post_id = (int) $post['post_id'];
		if ( !$post_id )
			continue;

		$del_post = get_post($post_id);

		if ( !$del_post || 'trash' != $del_post->post_status ) {
			delete_post_meta($post_id, '_wp_trash_meta_status');
			delete_post_meta($post_id, '_wp_trash_meta_time');
		} else {
			wp_delete_post($post_id);
		}
	}
	...
}

Problem

I have a custom post type called service that I would like to disable this feature on. At present time, you have one option that sort of meets that goal, which is to overwrite EMPTY_TRASH_DAYS and set it to some ridiculously high number, which means WordPress will never empty the trash, and while this solves your original problem, it creates another in that no trash will  ever be automatically emptied.

As a side note, if you set EMPTY_TRASH_DAYS to false or 0 (zero), the trash functionality is completely disabled, meaning every time you delete something, it’s permanent.

There are also no direct hooks available to manipulate this behavior.

Solution

The following seems like a rather complex solution for what seems like an easy problem, but it solves a problem without creating another (at least that I can think of). What we’re going to do is:

  • Unschedule WordPress’ default wp_scheduled_delete and prevent it from being scheduled again
  • Create our own hook and schedule an event to enqueue wp_schedule_delete
  • Hook into what we did above to delete the post meta field WordPress needs to delete service posts in the trash

1. Unschedule wp_scheduled_delete

The first thing we have to do is unschedule WordPress’ default wp_scheduled_delete, since we can’t hook into or manipulate it.

1
2
3
if(wp_next_scheduled('wp_scheduled_delete')) {
    wp_clear_scheduled_hook('wp_scheduled_delete');
}
if(wp_next_scheduled('wp_scheduled_delete')) {
	wp_clear_scheduled_hook('wp_scheduled_delete');
}

2. Prevent wp_scheduled_delete from being rescheduled

Next we have to ensure that WordPress doesn’t reschedule this event, otherwise every time an admin page is loaded the event would be scheduled and then unscheduled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
add_filter(
    'schedule_event',
    'custom_schedule_event',
    10,
    1
);
 
function custom_schedule_event($event) {
    switch($event->hook) {
        case 'wp_scheduled_delete':
            $event = false;
            break;
    }
    return $event;
}
add_filter(
	'schedule_event',
	'custom_schedule_event',
	10,
	1
);

function custom_schedule_event($event) {
	switch($event->hook) {
		case 'wp_scheduled_delete':
			$event = false;
			break;
	}
	return $event;
}

3. Create custom_schedule_cron_daily action hook

We’re going to create a custom hook called custom_schedule_cron_daily that will later be scheduled to be executed daily. In this hook we’ll execute all functions tied to the wp_scheduled_delete action hook.

1
2
3
4
5
6
7
8
add_action(
    'custom_schedule_cron_daily',
    'custom_execute_cron_daily'
);
 
function custom_execute_cron_daily() {
    do_action('wp_scheduled_delete');
}
add_action(
	'custom_schedule_cron_daily',
	'custom_execute_cron_daily'
);

function custom_execute_cron_daily() {
	do_action('wp_scheduled_delete');
}

What’s interesting is that there’s actually an action hook already in place for wp_scheduled_delete, but it’s not being enqueued anywhere.

1
2
3
// wp-includes/default-filters.php
 
add_action( 'wp_scheduled_delete', 'wp_scheduled_delete' );
// wp-includes/default-filters.php

add_action( 'wp_scheduled_delete', 'wp_scheduled_delete' );

4. Schedule custom_schedule_cron_daily daily event

Now we’re going to schedule our action hook that we created above to be executed daily by WP cron.

1
2
3
4
5
6
7
if(!wp_next_scheduled('custom_schedule_cron_daily')) {
    wp_schedule_event(
        time(),
        'daily',
        'custom_schedule_cron_daily'
    );
}
if(!wp_next_scheduled('custom_schedule_cron_daily')) {
	wp_schedule_event(
		time(),
		'daily',
		'custom_schedule_cron_daily'
	);
}

5. Create delete_post_meta_field_on_posts function

This is a function that will grab all specified posts of a post type and removes one meta field from each of them. In this case, we’re going to use it to get all trashed posts of our custom post type so we can remove the _wp_trash_meta_time field. Without this field, WordPress will no longer be able to get and delete those posts via wp_scheduled_delete.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function delete_post_meta_field_on_posts($post_type, $post_status, $meta_key) {
    global $wpdb;
    $processed_posts = array();
    if(empty($post_type)) {
        return $processed_posts;
    }
    if(empty($post_status)) {
        return $processed_posts;
    }
    if(empty($meta_key)) {
        return $processed_posts;
    }
    $query = 'SELECT wpp1.ID, wpp1.post_title, wppm1.meta_id, wppm1.meta_key, wppm1.meta_value ';
    $query .= 'FROM ' . $wpdb->posts . ' AS wpp1 ';
    $query .= 'LEFT JOIN ' . $wpdb->postmeta . ' AS wppm1 ON wpp1.ID = wppm1.post_id ';
    $query .= 'WHERE wpp1.post_type = %s ';
    $query .= 'AND post_status = %s ';
    $query .= 'AND wppm1.meta_key = %s';
    $posts = $wpdb->get_results(
        $wpdb->prepare(
            $query,
            $post_type, $post_status, $meta_key
        ),
        ARRAY_A
    );
    if(!empty($posts)) {
        foreach($posts as $post) {
            if($wpdb->delete($wpdb->postmeta, array('meta_id' => $post['meta_id']))) {
                $processed_posts[] = $post;
            }
        }
    }
    return $processed_posts;
}
function delete_post_meta_field_on_posts($post_type, $post_status, $meta_key) {
	global $wpdb;
	$processed_posts = array();
	if(empty($post_type)) {
		return $processed_posts;
	}
	if(empty($post_status)) {
		return $processed_posts;
	}
	if(empty($meta_key)) {
		return $processed_posts;
	}
	$query = 'SELECT wpp1.ID, wpp1.post_title, wppm1.meta_id, wppm1.meta_key, wppm1.meta_value ';
	$query .= 'FROM ' . $wpdb->posts . ' AS wpp1 ';
	$query .= 'LEFT JOIN ' . $wpdb->postmeta . ' AS wppm1 ON wpp1.ID = wppm1.post_id ';
	$query .= 'WHERE wpp1.post_type = %s ';
	$query .= 'AND post_status = %s ';
	$query .= 'AND wppm1.meta_key = %s';
	$posts = $wpdb->get_results(
		$wpdb->prepare(
			$query,
			$post_type, $post_status, $meta_key
		),
		ARRAY_A
	);
	if(!empty($posts)) {
		foreach($posts as $post) {
			if($wpdb->delete($wpdb->postmeta, array('meta_id' => $post['meta_id']))) {
				$processed_posts[] = $post;
			}
		}
	}
	return $processed_posts;
}

Let’s create another quick function to call the function above.

1
2
3
function delete_service_trash_meta_time() {
    return delete_post_meta_field_on_posts('service', 'trash', '_wp_trash_meta_time');
}
function delete_service_trash_meta_time() {
	return delete_post_meta_field_on_posts('service', 'trash', '_wp_trash_meta_time');
}

6. Hook into wp_scheduled_delete to execute delete_service_trash_meta_time

Since we’ve enqueued the action wp_scheduled_delete in step #3, we can now hook into it and perform additional actions.

1
2
3
4
5
6
7
8
add_action(
    'wp_scheduled_delete',
    'custom_wp_scheduled_delete'
);
 
function custom_wp_scheduled_delete() {
    delete_service_trash_meta_time();
}
add_action(
	'wp_scheduled_delete',
	'custom_wp_scheduled_delete'
);

function custom_wp_scheduled_delete() {
	delete_service_trash_meta_time();
}

That’s it. We basically did what WordPress has been doing all along, except we scheduled it ourselves in a way that allowed us to hook into it and perform additional actions, such as removing the meta field, during that time.

Note

Everywhere you see functions starting with custom, I just added that for readability and consistency. In reality, I use PHP namespaces to prevent naming collisions.

If you have any thoughts, recommendations or questions, feel free to leave them below.

8 thoughts on “Prevent WordPress from automatically purging trash from custom post type

  1. Brent

    Hi Ryan, great post! I especially like that you include the Preface to explain the problem.

    The first thing we have to do is unschedule WordPress’ default wp_scheduled_delete, since we can’t hook into or manipulate it.

    WP-Cron triggers an action, not a function (i.e. callback). That means we can hook in to run a function before the wp_scheduled_delete() function is called by attaching to the 'wp_scheduled_delete' action and using a priority lower than 10 (the default).

    Specifically, just change the call in step 6 to:

    1
    
    add_action( 'wp_scheduled_delete', 'custom_wp_scheduled_delete', 9 );
    add_action( 'wp_scheduled_delete', 'custom_wp_scheduled_delete', 9 );

    (Note the 9 there).

    That will mean custom_wp_scheduled_delete() will be called before WordPress’ wp_scheduled_delete() function, and so the meta data will be removed before it is checked. So you can actually skip everything from step 1-4 completely.

    What’s interesting is that there’s actually an action hook already in place for wp_scheduled_delete, but it’s not being enqueued anywhere.

    As above, WP-Cron triggers the 'wp_scheduled_delete' hook which fires the wp_scheduled_delete() function.

    Hooking in with an earlier priority works for this case, but we could also prevent the wp_scheduled_delete() function being called completely with the code:

    1
    
    remove_action( 'wp_scheduled_delete', 'wp_scheduled_delete' );
    remove_action( 'wp_scheduled_delete', 'wp_scheduled_delete' );

    Hope that helps you and others the same way your post helped save me a bunch of time tracing the code. 🙂

    Reply
    1. Ryan Sechrest Post author

      Very nice, Brent! Thanks for sharing your improvement. Back then I was searching through WordPress files for wp_scheduled_delete to be fired, but now realize that the hook names are retrieved from the database. PS: Good use of HTML tags in your comment 😉

      Reply
  2. awesome script

    Hi Ryan would you be willing to turn this into a WordPress plugin?

    There are no good alternative plugins. I’ve tried adding some basic lines to wp-config but they never seem to work and trash always gets cleared. Honestly, WP should have a setting to prevent auto purge.

    Reply
    1. Ryan Sechrest Post author

      If you added the code to wp-config.php, it wouldn’t work. You would either need to add this to your theme’s functions.php file or create a really basic plugin. That said, take a look at Brent’s comment. I haven’t tested what he suggested, but it makes sense and would be much simpler to implement.

      With regard to creating a plugin, I can’t commit to that right now. I don’t want to be the guy who creates a plugin and then doesn’t make time to maintain it.

      Reply
  3. serge

    Simply remove the post meta key '_wp_trash_meta_time':

    add_action ( 'trashed_post', 'do_not_schedule_post_types' );
    
    function do_not_schedule_post_types( $post_id ) {
    	
    	// array of post_type you don't want to be scheduled
    	$unscheduled_post_types = array( 'MyPostType');
    
    	if ( in_array( get_post_type( $post_id ), $unscheduled_post_types ) ) {
    		delete_post_meta( $post_id, '_wp_trash_meta_time' );
    	}
    }
    Reply
    1. Ryan Sechrest Post author

      Hi Serge! Looking at my solution above after all this time, I don’t remember what lead me to the roundabout way of deleting the meta field, but looking at your much simpler solution, I think it’s great. Thanks for sharing that!

      Reply
  4. buscador de empleos

    i have a problem and i don’t know how to fix it, i need to delete all the wp_trash_time and all this kinds of information, what i need to for delete all ones and also for generate an script for indicate to my wordpress to don’t generate this in a future?

    Reply
    1. Ryan Sechrest Post author

      Hello! You could do a one-time delete of all _wp_trash_meta_time post metas and then use something like Serge’s answer above to delete them as they are generated. As to preventing them from being created, I don’t think you can, because it’s built-in if you enable the trash.

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *