For one of my projects, I needed to create a custom post type that did not support the title and editor features. That custom post type heavily relied upon custom post fields; from those custom fields the plugin constructed the title and content for WordPress.

Post revisions did not work out of the box because we stored all the data inside custom fields. Proper support for post revisions required some custom coding.

The custom post type was registered like this:

$args = [
    // ...
    'supports'            => [ 'comments', 'revisions' ],
    'taxonomies'          => [ 'post_tag' ],
    'hierarchical'        => false,
    // ...
];

register_post_type( 'mycpt', $args );

The editor interface was implemented via a metabox:

add_action( 'add_meta_boxes', function ( $post_type ) {
    if ( 'mycpt' === $post_type ) {
        add_meta_box(
        'mycpt_editor',
        __( 'Title', 'myplugin' ),
        function ( WP_Post $post) {
            $field1 = get_post_meta( $post->ID, '_metakey1', true );
            $field2 = get_post_meta( $post->ID, '_metakey2', true );
            // ...
            require __DIR__ . '/views/metabox.php';
        },
        'mycpt',
        'normal',
        'high'
    );
} );

So far, nothing unusual (except that I have inlined all callbacks for brevity).

Many tutorials recommend using the save_post hook to save the data coming from the metabox. Like this:

add_action( 'save_post', function ( $post_id, WP_Post $post ) {
    if ( 'mycpt' !== $post->post_type ) {
        return;
    }

    // Do other sanity checks here like nonce validation, permission check, etc
    // ...

    // Save the data
    if ( ! empty( $_POST['metakey1'] ) {
        update_post_meta( $post_id, '_metakey1', wp_slash( $_POST['metakey1'] ) );
    } else {
        delete_post_meta( $post_id, '_metakey1' );
    }

    // Process other fields
    // ...
}, 10, 2 );

This code works, but post revisions don’t. Why? To understand why, we need to find out how the save_post_revision() function works.

The save_post_revision() function uses a helper, _wp_post_revision_fields(). That helper returns the fields which save_post_revision() will test for changes. By default, those are: post_title, post_content, and post_excerpt. If those fields are the same in the current and the previous versions of the post, the function will not create a new revision. To modify this behavior, we can use the wp_save_post_revision_post_has_changed hook: we can implement a custom comparison logic using the custom fields.

But, here comes another gotcha: update_post_meta() does not work with revisions; instead, it operates on the parent post. While it is possible to retrieve metadata of a revision, it is not possible to set them using the WordPress API.

There are several possible workarounds, such as:

  • use an external table to store the actual content (this can be good performance-wise);
  • use the post_content_filtered field of the wp_posts table;
  • use more metadata for versioning.

Another important thing to mention is that wp_post_save_revision() runs during the post_updated event. Therefore, we need to make sure that we have all the necessary data to create a revision available before that event happens. To do this, we can use the edit_post_{$post->post_type} hook. Note that the edit_post hooks run only on post update; to watch post insert, you need to use save_post hooks.

add_action( 'edit_post_mycpt', function ( int $post_id, WP_Post $post ) {
    // edit_post and save_post share the same logic
    $this->save_post_mycpt( $post_id, $post, false );
}, 10, 2 );

add_action( 'save_post_mycpt', function ( int $post_id, WP_Post $post, bool $is_update ) {
    if ( $is_update ) {
        // Insert and update logic is the same in our case.
        // Bail out when $is_update is true because we have already run this code
        // during the edit_post_mycpt.
        return;
    }

    // Run security and sanity checks here
    // ...

    // Save the data
    if ( ! empty( $_POST['metakey1'] ) {
        update_post_meta( $post_id, '_metakey1', wp_slash( $_POST['metakey1'] ) );
    } else {
        delete_post_meta( $post_id, '_metakey1' );
    }

    // Process other fields
    // ...
}, 10, 3 );

The code above saves the data for the edited post and makes sure that all updates are done before WordPress attempts to create a new revision.

To save the revision, we can re-use the above approach: add a handler for the save_post_revision action, then check if the parent post is of “our” type and, finally, implement the save logic:

add_action( 'save_post_revision', function ( int $post_id, WP_Post $post ) {
    if ( ! $post->post_parent || 'mycpt' !== get_post_type( $post->post_parent ) ) {
        // The parent post is not of our "mycpt" type, exit
        return;
    }

    // This is the original post
    $parent = get_post( $post->post_parent );

    // Run security and sanity checks here
    // ...

    // Save the data
    // ...
}, 10, 2 );

However, if we want to use the post_content_filtered field to store the metadata, we had better use the wp_insert_post_data hook:

add_filter( 'wp_insert_post_data', function ( array $data ) {
    if ( 'revision' === $data['post_type'] ) {
        $parent = get_post( $data['post_parent'] );
        if ( $parent && 'mycpt' === $parent->post_type ) {
            // Get all the metadata - you need to implement this
            $metadata                      = get_all_metadata( $parent );
            $data['post_content_filtered'] = wp_slash( (string) wp_json_encode( $metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) );
        }
    }

    return $data;
} );

OK, now that we can save revisions, we need to be able to restore them. Let us look at the wp_restore_post_revision() function. Its logic is pretty straightforward: it retrieves the revision by its ID, gets the list of fields to restore (with the already familiar _wp_post_revision_fields() method), overwrites post’s fields with revision’s fields, saves the post (using wp_update_post()), and, finally, runs the wp_restore_post_revision action.

This is how the code to restore a revision might look:

// Optional: do not propagate post_content_filtered to the main post
add_filter( 'wp_insert_post_data', function ( array $data ) {
    if ( 'mycpt' === $data['post_type'] ) {
        $data['post_content_filtered'] = '';
    }

    return $data;
} );

add_action( 'wp_restore_post_revision', function ( int $post_id, int $revision_id ) {
    $post     = get_post( $post_id );
    $revision = get_post( $revision_id );

    if ( $post && $revision && 'mycpt' === $post->post_type && $revision->post_content_filtered ) {
        $meta = json_decode( $revision->post_content_filtered, true );
        if ( is_array( $meta ) ) {
            // Restore post metadata from $meta
            // ...
        }
    }
}, 10, 2 );

Now we have a working save and restore logic. But what about the UI?

To display the user interface, WordPress uses the _wp_post_revision_fields() function (and we can control its return value with the _wp_post_revision_fields hook). However, all functions that use _wp_post_revision_fields(), assume that we compare the fields from the wp_posts table. Like this:

foreach ( array_keys( _wp_post_revision_fields( $post ) ) as $field ) {
	if ( normalize_whitespace( $post->$field ) !== normalize_whitespace( $last_revision->$field ) ) {
		$post_has_changed = true;
		break;
	}
}

As a last resort, if we store our metadata as a JSON string in the post_content_filtered field, we can do something like this:

add_filter( '_wp_post_revision_fields', function ( array $fields, array $post ) {
    if ( 'mycpt' === $post['post_type'] || 'revision' === $post['post_type'] && 'mycpt' === get_post_type( (int) $post['post_parent'] ) ) {
        $fields['post_content_filtered'] = __( 'Internal metadata', 'myplugin' );
    }

    return $fields;
}, 10, 2 );

The user will be presented with a diff of the metadata. In some cases, this could still be better than nothing.

I can think of one hack to overcome this issue, but this is indeed a hack, so caveat emptor!

The idea behind the hack is that we check the caller of our _wp_post_revision_fields handler. If the caller is wp_get_revision_ui_diff(), we present a different set of fields and use the _wp_post_revision_field_{$field} filter to populate them.

Like this:

add_filter( '_wp_post_revision_fields', function ( array $fields, array $post ) {
    if ( 'mycpt' === $post['post_type'] || 'revision' === $post['post_type'] && 'mycpt' === get_post_type( (int) $post['post_parent'] ) ) {
        $e        = new Exception();
        $trace    = $e->getTrace();
        $for_diff = false;
        $length   = count( $trace );
        if ( $length > 4 ) {
            // Frame 0: this method
            // Frame 1: WP_Hook::apply_filters
            // Frame 2: apply_filters
            // Frame 3: _wp_post_revision_fields
            // Frame 4: <caller>

            for ( $i = 4; $i < $length; ++$i ) {
                if ( ! isset( $trace[ $i ]['class'] ) && 'wp_get_revision_ui_diff' === $trace[ $i ]['function'] ) {
                    $for_diff = true;
                    break;
                }
            }
        }

        if ( $for_diff ) {
            $fields['field1'] = __( 'Field 1', 'myplugin' );
            $fields['field2'] = __( 'Field 2', 'myplugin' );
        }

        $fields['post_content_filtered'] = __( 'Internal metadata', 'myplugin' );
    }

    return $fields;
}, 10, 2 );

add_filter( '_wp_post_revision_field_field1', 'myplugin_wp_post_revision_field_for_diff', 10, 3 );
add_filter( '_wp_post_revision_field_field2', 'myplugin_wp_post_revision_field_for_diff', 10, 3 );

function myplugin_wp_post_revision_field_for_diff( $value, string $field, WP_Post $compare_from ): string {
    if ( ! empty( $compare_from->post_content_filtered ) ) {
        $meta = json_decode( $compare_from->post_content_filtered, true );
        if ( is_array( $meta ) ) {
            return (string) ( $meta[ $field ] ?? '' );
        }
    }

    return (string) $value;
}

This will show “Field 1” and “Field 2” in the “compare Revisions” UI.

How to Implement Revisions for Non-Standard Custom Post Types in WordPress
Tagged on:             

Leave a Reply

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