Maintaining strict one-to-one association between terms and custom posts

It’s difficult, but not impossible to maintain the one-to-one relationship between a taxonomy and a custom post by using many hooks. Because WordPress is designed to allow many posts to tag many terms, there are various hooks that need to be used to prevent the creation of terms without custom posts and vice versa.

The code below shows the complete system, including the registration of the custom post type and taxonomy. It has hooks to trigger the creation of the corresponding taxonomy terms when new posts are made in the custom post type, and to handle when they are edited or deleted. It also disables the term editor and various user permissions to stop users from being able to edit the taxonomy terms directly. It also overrides term links on the front end to no longer point to the term archive pages on posts tagged with the term, and instead link to the corresponding custom single post. Finally, it removes any invalid terms that a normal (non-custom) post get tagged with in the custom taxonomy upon save, because WordPress lets the user create new terms via the editor, which we don’t want.

An example of this code in a working environment can be seen in my plugin, Academic Labbook Plugin.

// Register the custom post type.
function wpse_344265_register_post_type() {
    $args = array(
        'labels'       => $labels,
        'description'  => __( 'My custom post type.', 'my-text-domain' ),
        'public'       => true,
        'hierarchical' => false,
        'show_in_rest' => true,
    );

    register_post_type( 'my-custom-post-type', $args );
}
add_action( 'init', 'wpse_344265_register_post_type' );

// Create corresponding custom taxonomy terms whenever posts are created.
function wpse_344265_associate_custom_post_with_term( $post_id, $post ) {
    if ( 'my-custom-post-type' !== $post->post_type ) {
        return;
    }

    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        // Don't create term for autosaves.
        return;
    }

    if ( 'publish' !== $post->post_status ) {
        // Don't create a term unless the post is being published.
        return;
    }

    // Add or update the associated term.
    wpse_344265_update_custom_taxonomy_term( $post );
}
add_action( 'save_post', 'wpse_344265_associate_custom_post_with_term', 10, 2 );

// Delete corresponding custom taxonomy terms whenever posts are deleted.
function wpse_344265_delete_associated_custom_post_term( $post_id ) {
    $post = get_post( $post_id );

    if ( is_null( $post ) ) {
        // Invalid post.
        return;
    }

    if ( 'my-custom-post-type' !== $post->post_type ) {
        return;
    }

    $term = wpse_344265_get_custom_term( $post );

    if ( ! $term ) {
        // No term to delete.
        return;
    }

    wp_delete_term( $term->term_id, 'my-custom-taxonomy' );
    clean_term_cache( array( $term->term_id ), 'my-custom-taxonomy' );
}
add_action( 'deleted_post', 'wpse_344265_delete_associated_custom_post_term' );

function wpse_344265_update_custom_taxonomy_term( $post ) {
    $post = get_post( $post );

    if ( is_null( $post ) ) {
        // Invalid post.
        return;
    }

    // Get custom term, if present.
    $term = wpse_344265_get_custom_term( $post );

    // Temporarily disable the filter that blocks creation of terms.
    remove_filter( 'pre_insert_term', 'wpse_344265_disallow_insert_term', 10, 2 );

    if ( ! $term ) {
        // Term doesn't yet exist.
        $args = array(
            'slug' => wpse_344265_get_custom_taxonomy_term_slug( $post ),
        );

        wp_insert_term( $post->post_title, 'my-custom-taxonomy', $args );
    } else {
        // Update term.
        $args = array(
            'name' => $post->post_title,
            'slug' => wpse_344265_get_custom_taxonomy_term_slug( $post ),
        );

        wp_update_term( $term->term_id, 'my-custom-taxonomy', $args );
    }

    // Re-enable the filter.
    add_filter( 'pre_insert_term', wpse_344265_disallow_insert_term, 10, 2 );
}

function wpse_344265_get_custom_term( $post ) {
    $post = get_post( $post );

    if ( is_null( $post ) ) {
        // Invalid post.
        return;
    }

    $slug = wpse_344265_get_custom_taxonomy_term_slug( $post );
    return get_term_by( 'slug', $slug, 'my-custom-taxonomy' );
}

function wpse_344265_get_custom_taxonomy_term_slug( $post ) {
    $post = get_post( $post );

    if ( is_null( $post ) ) {
        // Invalid post.
        return;
    }

    // Use the custom post's ID since this doesn't change even if e.g. its slug does.
    return $post->ID;
}

function wpse_344265_register_taxonomy() {
    $args = array(
        'hierarchical'      => false,
        'labels'            => array(
            'name'          => __( 'My custom taxonomy', 'my-text-domain' ),
        ),
        'capabilities' => array(
            'manage_terms'  =>   'do_not_allow',
            'edit_terms'    =>   'do_not_allow',
            'delete_terms'  =>   'do_not_allow',
            'assign_terms'  =>   'edit_posts', // Needed to allow assignment in block editor.
        ),
        'public'            => true,
        'show_in_menu'      => false, // Hides the term edit page.
        'show_in_rest'      => true,  // Needed for block editor support.
        'show_admin_column' => true,  // Show associated terms in admin edit screen.
    );

    $supported_post_types = array(
        'post',
    );

    register_taxonomy( 'my-custom-taxonomy', $supported_post_types, $args );
}
add_action( 'init', 'wpse_344265_register_taxonomy' );

// Override default term links to point towards the term custom post instead of term
// archive.
function wpse_344265_override_term_link( $link, $term, $taxonomy ) {
    if ( 'my-custom-taxonomy' !== $taxonomy ) {
        return $link;
    }

    $my_custom_post = wpse_344265_get_post_from_custom_term( $term );

    if ( is_null( $my_custom_post ) ) {
        return $link;
    }

    return get_permalink( $my_custom_post );
}
add_filter( 'term_link', 'wpse_344265_override_term_link', 10, 3 );

function wpse_344265_get_post_from_custom_term( $term ) {
    // The term's slug is the post ID.
    return get_post( $term->slug );
}

// Disallow creation of new terms directly.
function wpse_344265_disallow_insert_term( $term, $taxonomy ) {
    if ( 'my-custom-taxonomy' !== $taxonomy ) {
        return $term;
    }

    // Return an error in all circumstances.
    return new WP_Error(
        'wpse_344265_disallow_insert_term',
        __( 'Your role does not have permission to add terms to this taxonomy', 'my-text-domain' )
    );
}
add_filter( 'pre_insert_term', 'wpse_344265_disallow_insert_term', 10, 2 );

// Delete any invalid custom taxonomy items when post terms are set.
function wpse_344265_reject_invalid_terms( $object_id, $tt_id, $taxonomy ) {
    if ( 'my-custom-taxonomy' !== $taxonomy ) {
        return;
    }

    $term = get_term_by( 'term_taxonomy_id', $tt_id, 'my-custom-taxonomy' );

    if ( ! $term ) {
        // Nothing to do here.
        return;
    }

    // Check term is valid.
    $custom_post = wpse_344265_get_post_from_custom_term( $term );

    if ( ! $custom_post ) {
        // This is not a valid custom taxonomy term - delete it.
        wp_delete_term( $term->term_id, 'my-custom-taxonomy' );
    }
}
add_action( 'added_term_relationship', 'wpse_344265_reject_invalid_terms', 10, 3 );

Leave a Comment