Many to Many Relationship between Two Custom Post Types

As Milo noted, there is no built in way to do a many to many relationship between two custom post types.

That being said, these days it isn’t too hard to do it yourself without a plugin if wanted.

I do many to many relationships between custom post types by using non-singular post meta.

e.g. Books and Authors

Stick an author_id post meta (non-singular) on a book and you can now have:

  • Any number of authors related to a book
  • Any number of books related to an author
  • A fairly easy way for one to query the other

Adding the relationships into the post editors

The below class takes care of creating this kind of relationship and adding it to post editors. For this example, it is assumed that one post type is called ‘book’ and the other is called ‘author’. Yes, terrible naming … but it helps illustrate how it works.

One might need to change ‘author’ to ‘writer’ or some such as I can’t remember off the top of my head if author is an actual post type or not.

class Many_To_Many_Linker {

    protected $_already_saved = false;  # Used to avoid saving twice

    public function __construct() {
        $this->do_initialize();
    }

    protected function do_initialize() {

        add_action(
            'save_post',
            array( $this, 'save_meta_box_data' ),
            10,
            2
        );

        add_action(
            "add_meta_boxes_for_author",
            array( $this, 'setup_author_boxes' )
        );

        add_action(
            "add_meta_boxes_for_book",
            array( $this, 'setup_book_boxes' )
        );

    }

Setting up 2 author meta boxes

We set up one author meta box to show the book relationships and another to hold some extra data about the author.
We do not put this all in one meta field because we want to be able to easily query book author relationships.
If we stuck the book_ids into the serialized array used for the author extra data, we could not easily query it.

By using non-singular post meta named ‘_author_id’ on each book we gain the ability to:

  • Easily query all authors related to a specific book
  • Easily query all books related to a specific author

If we wanted to be able to easily query other things about an author, we could either break them out of the serialized array or use SQL like clauses to seek them out even if they are in an array, but breaking them out is much cleaner.

    public function setup_author_boxes( \WP_Post $post ) {
        add_meta_box(
            'author_related_books_box',
            __('Related Books', 'language'),
            array( $this, 'draw_author_books_box' ),
            $post->post_type,
            'advanced',
            'default'
        );

        add_meta_box(
            'author_extra_data',
            __('Author Details', 'language'),
            array( $this, 'draw_author_details_box' ),
            $post->post_type,
            'advanced',
            'default'
        );
    }

We use a calling function since we need the details meta in multiple functions.
We also use a get_defaults call so we can keep the defaults noted in once place in case we need to change them.

    protected function get_author_details_meta( $post_id = 0 ) {
        $default = $this->get_default_author_details_meta();
        $current = get_post_meta( $post_id, '_author_info', true );
        if ( !is_array( $current ) ) {
            $current = $default;
        } else {
            foreach ( $default as $k => $v ) {
                if ( !array_key_exists( "{$k}", $current ) ) {
                    $current["{$k}"] = $v;
                }
            }
        }
        return $current;
    }

    protected function get_default_author_details_meta() {
        return array(
            'favorite_color' => '',
            'height' => '',
            'eye_color' => ''
        );
    }

The Author Details Box

We just grab their meta and display it in text boxes for this example.

    public function draw_author_details_box( \WP_Post $post ) {

        $current_meta = $this->get_author_details_meta( $post->ID );

            echo <<<HTML
<p>
    <label for="author_favorite_color">Favorite Color:</label>
    <input type="text" name="author_favorite_color" value="{$current_meta['favorite_color']}" id="author_favorite_color" />
</p>
<p>
    <label for="author_height">Height:</label>
    <input type="text" name="author_height" value="{$current_meta['height']}" id="author_height" />
</p>
<p>
    <label for="author_eye_color">Eye Color:</label>
    <input type="text" name="author_eye_color" value="{$current_meta['eye_color']}" id="author_eye_color" />
</p>
HTML;

        # No need for nonce - already added in related books

    }

The Related Books Box

We build a list of check boxes for this example so the user can set them all if wanted.
We could add a check_all type box with some javascript, break them out by category, etc.
And yes, if we had thousands of books in the system this would probably not be the best plan, but it works great if we only have a few hundred books.

    public function draw_author_books_box( \WP_Post $post ) {

        $all_books = $this->get_all_of_post_type( 'book' );

        $linked_book_ids = $this->get_author_book_ids( $post->ID );

        if ( 0 == count($all_books) ) {
            $choice_block = '<p>No books found in the system.</p>';
        } else {
            $choices = array();
            foreach ( $all_books as $book ) {
                $checked = ( in_array( $book->ID, $linked_book_ids ) ) ? ' checked="checked"' : '';

                $display_name = esc_attr( $book->post_title );
                $choices[] = <<<HTML
<label><input type="checkbox" name="book_ids[]" value="{$book->ID}" {$checked}/> {$display_name}</label>
HTML;

            }
            $choice_block = implode("\r\n", $choices);
        }

        # Make sure the user intended to do this.
        wp_nonce_field(
            "updating_{$post->post_type}_meta_fields",
            $post->post_type . '_meta_nonce'
        );

        echo $choice_block;
    }

Grabbing all posts of a type

We use a generic function for this as we need it for both post types.

    # Grab all posts of the specified type
    # Returns an array of post objects
    protected function get_all_of_post_type( $type_name="") {
        $items = array();
        if ( !empty( $type_name ) ) {
            $args = array(
                'post_type' => "{$type_name}",
                'posts_per_page' => -1,
                'order' => 'ASC',
                'orderby' => 'title'
            );
            $results = new \WP_Query( $args );
            if ( $results->have_posts() ) {
                while ( $results->have_posts() ) {
                    $items[] = $results->next_post();
                }
            }
        }
        return $items;
    }

Getting books for an author

Books have an _author_id set on them which is a multi-value post meta so we can have one book related to many authors.

This function grabs the book ids as an array.

    # Get array of book ids for a particular author id
    protected function get_author_book_ids( $author_id = 0 ) {
        $ids = array();
        if ( 0 < $author_id ) {
            $args = array(
                'post_type' => 'book',
                'posts_per_page' => -1,
                'order' => 'ASC',
                'orderby' => 'title',
                'meta_query' => array(
                    array(
                        'key' => '_author_id',
                        'value' => (int)$author_id,
                        'type' => 'NUMERIC',
                        'compare' => '='
                    )
                )
            );
            $results = new \WP_Query( $args );
            if ( $results->have_posts() ) {
                while ( $results->have_posts() ) {
                    $item = $results->next_post();
                    if ( !in_array($item->ID, $ids) ) {
                        $ids[] = $item->ID;
                    }
                }
            }
        }
        return $ids;
    }

Setting up book related authors

We will just have a related authors box for now.

We draw it out just like the author related books box.

    public function setup_book_boxes( \WP_Post $post ) {
        add_meta_box(
            'book_related_authors_box',
            __('Related Authors', 'language'),
            array( $this, 'draw_book_authors_box' ),
            $post->post_type,
            'advanced',
            'default'
        );
    }

    public function draw_book_authors_box( \WP_Post $post ) {

        $all_authors = $this->get_all_of_post_type( 'author' );

        $linked_author_ids = $this->get_book_author_ids( $post->ID );

        if ( 0 == count($all_authors) ) {
            $choice_block = '<p>No authors found in the system.</p>';
        } else {
            $choices = array();
            foreach ( $all_authors as $author ) {
                $checked = ( in_array( $author->ID, $linked_author_ids ) ) ? ' checked="checked"' : '';

                $display_name = esc_attr( $author->post_title );
                $choices[] = <<<HTML
<label><input type="checkbox" name="author_ids[]" value="{$author->ID}" {$checked}/> {$display_name}</label>
HTML;

            }
            $choice_block = implode("\r\n", $choices);
        }

        # Make sure the user intended to do this.
        wp_nonce_field(
            "updating_{$post->post_type}_meta_fields",
            $post->post_type . '_meta_nonce'
        );

        echo $choice_block;
    }

Getting a books related authors

This is just a matter of grabbing the post meta and noting that it is not singular.

    # Grab all properties related to a specific development area
    # Returns an array of property post ids
    protected function get_book_author_ids( $book_id = 0 ) {
        $ids = array();
        if ( 0 < $book_id ) {
            $matches = get_post_meta( $book_id, '_author_id', false);
            if ( 0 < count($matches) ) {
                $ids = $matches;
            }
        }
        return $ids;
    }

Saving our meta box data

We do a bunch of sanity checking and then, if needed, hand off actual processing to helper functions.

    public function save_meta_box_data( $post_id = 0, \WP_Post $post = null ) {

        $do_save = true;

        $allowed_post_types = array(
            'book',
            'author'
        );

        # Do not save if we have already saved our updates
        if ( $this->_already_saved ) {
            $do_save = false;
        }

        # Do not save if there is no post id or post
        if ( empty($post_id) || empty( $post ) ) {
            $do_save = false;
        } else if ( ! in_array( $post->post_type, $allowed_post_types ) ) {
            $do_save = false;
        }

        # Do not save for revisions or autosaves
        if (
            defined('DOING_AUTOSAVE')
            && (
                is_int( wp_is_post_revision( $post ) )
                || is_int( wp_is_post_autosave( $post ) )
            )
        ) {
            $do_save = false;
        }

        # Make sure proper post is being worked on
        if ( !array_key_exists('post_ID', $_POST) || $post_id != $_POST['post_ID'] ) {
            $do_save = false;
        }

        # Make sure we have the needed permissions to save [ assumes both types use edit_post ]
        if ( ! current_user_can( 'edit_post', $post_id ) ) {
            $do_save = false;
        }

        # Make sure the nonce and referrer check out.
        $nonce_field_name = $post->post_type . '_meta_nonce';
        if ( ! array_key_exists( $nonce_field_name, $_POST) ) {
            $do_save = false;
        } else if ( ! wp_verify_nonce( $_POST["{$nonce_field_name}"], "updating_{$post->post_type}_meta_fields" ) ) {
            $do_save = false;
        } else if ( ! check_admin_referer( "updating_{$post->post_type}_meta_fields", $nonce_field_name ) ) {
            $do_save = false;
        }

        if ( $do_save ) {
            switch ( $post->post_type ) {
                case "book":
                    $this->handle_book_meta_changes( $post_id, $_POST );
                    break;
                case "author":
                    $this->handle_author_meta_changes( $post_id, $_POST );
                    break;
                default:
                    # We do nothing about other post types
                    break;
            }

            # Note that we saved our data
            $this->_already_saved = true;
        }
        return;
    }

Saving Author Data

We have two meta boxes to process, our details box and our related books box.

For the details box, processing is simple. I only update if absolutely needed.

For the related books box, we compare our currently related books to those checked by the user and add or remove relationships as needed, talking care to make sure we don’t quash the many to many relationship we set up.

Note: We are working on book meta data in the related books section, not our own.

    # Authors can be linked to multiple books
    # Notice that we are editing book meta data here rather than author meta data
    protected function handle_author_meta_changes( $post_id = 0, $data = array() ) {

        # META BOX - Details
        $current_details = $this->get_author_details_meta( $post_id );

        if ( array_key_exists('favorite_color', $data) && !empty($data['favorite_color'] ) ) {
            $favorite_color = sanitize_text_field( $data['favorite_color'] );
        } else {
            $favorite_color="";
        }
        if ( array_key_exists('height', $data) && !empty($data['height'] ) ) {
            $height = sanitize_text_field( $data['height'] );
        } else {
            $height="";
        }
        if ( array_key_exists('eye_color', $data) && !empty($data['eye_color'] ) ) {
            $eye_color = sanitize_text_field( $data['eye_color'] );
        } else {
            $eye_color="";
        }

        $changed = false;

        if ( $favorite_color != "{$current_details['favorite_color']}" ) {
            $current_details['favorite_color'] = $favorite_color;
            $changed = true;
        }

        if ( $height != "{$current_details['height']}" ) {
            $current_details['height'] = $height;
            $changed = true;
        }

        if ( $eye_color != "{$current_details['eye_color']}" ) {
            $current_details['eye_color'] = $eye_color;
            $changed = true;
        }

        if ( $changed ) {
            update_post_meta( $post_id, '_author_info', $current_details );
        }

        # META BOX - Related Books

        # Get the currently linked books for this author
        $linked_book_ids = $this->get_author_book_ids( $post_id );

        # Get the list of books checked by the user
        if ( array_key_exists('book_ids', $data) && is_array( $data['book_ids'] ) ) {
            $chosen_book_ids = $data['book_ids'];
        } else {
            $chosen_book_ids = array();
        }

        # Build a list of books to be linked or unlinked from this author
        $to_remove = array();
        $to_add = array();

        if ( 0 < count( $chosen_book_ids ) ) {
            # The user chose at least one book to link to
            if ( 0 < count( $linked_book_ids ) ) {
                # We already had at least one book linked

                # Cycle through existing and note any that the user did not have checked
                foreach ( $linked_book_ids as $book_id ) {
                    if ( ! in_array( $book_id, $chosen_book_ids ) ) {
                        # Currently linked, but not chosen. Remove it.
                        $to_remove[] = $book_id;
                    }
                }

                # Cycle through checked and note any that are not currently linked
                foreach ( $chosen_book_ids as $book_id ) {
                    if ( ! in_array( $book_id, $linked_book_ids ) ) {
                        # Chosen but not in currently linked. Add it.
                        $to_add[] = $book_id;
                    }
                }

            } else {
                # No previously chosen ids, simply add them all
                $to_add = $chosen_book_ids;
            }

        } else if ( 0 < count( $linked_book_ids ) ) {
            # No properties chosen to be linked. Remove all currently linked.
            $to_remove = $linked_book_ids;
        }

        if ( 0 < count($to_add) ) {
            foreach ( $to_add as $book_id ) {
                # We use add post meta with 4th parameter false to let us link
                # books to as many authors as we want.
                add_post_meta( $book_id, '_author_id', $post_id, false );
            }
        }

        if ( 0 < count( $to_remove ) ) {
            foreach ( $to_remove as $book_id ) {
                # We specify parameter 3 as we only want to delete the link
                # to this author
                delete_post_meta( $book_id, '_author_id', $post_id );
            }
        }
    }

Saving Related Authors for Books

Pretty much the same as saving related books for authors, except we are working on our own post meta.

    # Books can be linked with multiple authors
    protected function handle_book_meta_changes( $post_id = 0, $data = array() ) {

        # Get the currently linked authors for this book
        $linked_author_ids = $this->get_book_author_ids( $post_id );

        # Get the list of authors checked by the user
        if ( array_key_exists('author_ids', $data) && is_array( $data['author_ids'] ) ) {
            $chosen_author_ids = $data['author_ids'];
        } else {
            $chosen_author_ids = array();
        }

        # Build a list of authors to be linked or unlinked with this book
        $to_remove = array();
        $to_add = array();

        if ( 0 < count( $chosen_author_ids ) ) {
            # The user chose at least one author to link to
            if ( 0 < count( $linked_author_ids ) ) {
                # We already had at least one author already linked

                # Cycle through existing and note any that the user did not have checked
                foreach ( $linked_author_ids as $author_id ) {
                    if ( ! in_array( $author_id, $chosen_author_ids ) ) {
                        # Currently linked, but not chosen. Remove it.
                        $to_remove[] = $author_id;
                    }
                }

                # Cycle through checked and note any that are not currently linked
                foreach ( $chosen_author_ids as $author_id ) {
                    if ( ! in_array( $author_id, $linked_author_ids ) ) {
                        # Chosen but not in currently linked. Add it.
                        $to_add[] = $author_id;
                    }
                }

            } else {
                # No previously chosen ids, simply add them all
                $to_add = $chosen_author_ids;
            }

        } else if ( 0 < count( $linked_author_ids ) ) {
            # No properties chosen to be linked. Remove all currently linked.
            $to_remove = $linked_author_ids;
        }

        if ( 0 < count($to_add) ) {
            foreach ( $to_add as $author_id ) {
                # We use add post meta with 4th parameter false to let us link
                # to as many authors as we want.
                add_post_meta( $post_id, '_author_id', $author_id, false );
            }
        }

        if ( 0 < count( $to_remove ) ) {
            foreach ( $to_remove as $author_id ) {
                # We specify parameter 3 as we only want to delete the link
                # to this author
                delete_post_meta( $post_id, '_author_id', $author_id );
            }
        }
    }

} # end of the class declaration

Getting the class to work

So long as you load the class before things are set up ( include it or inline it in a plugin, locate_template the class function in your theme functions.php, etc ), you can use it easily as follows:

if ( is_admin() ) {
    new Many_To_Many_Linker();
}

It will take care of setting everything up
Some example front end uses

Now that we have set up meta boxes in the admin panels that let us easily mark all books for an author and vice versa, let’s put things to work.

First, a couple of starting helper functions:

Getting all books for a given author

Given an author id, returns an array of book post objects.

function get_books_for_author_id( $author_id = 0 ) {
    $found = array();

    if ( 0 < $author_id ) {
        $args = array(
            'post_type' => 'book',
            'posts_per_page' => -1,
            'meta_query' => array(
                array(
                    'key' => '_author_id',
                    'value' => $author_id,
                    'type' => 'NUMERIC',
                    'compare' => '='
                )
            )
        );
        $books = new \WP_Query( $args );
        if ( $books->have_posts() ) {
            while ( $books->have_posts() ) {
                $book = $books->next_post();
                $found["{$book->ID}"] = $book;
            }
        }
    }

    return $found;
}

Grab an authors extra data

We could shape this up by setting defaults and/or mapping keys to nicer names.
For now, we just want the data or an empty array.

function get_author_extra_data_for_author_id( $author_id = 0 ) {
    $data = array();
    if ( 0 < $author_id ) {
        $current = get_post_meta( $author_id, '_author_info', true );
        if ( is_array($current) ) {
            $data = $current;
        }
    }
    return $data;
}

Getting Book and Extra Data in the Main Loop

This example assumes you are in the main loop (e.g. one of the following is true)

  • is_post_type_archive(‘author’)
  • is_singular(‘author’)

That being said, anywhere we have a known author id we can use the above helper functions to pull their books and extra information.

while ( have_posts() ) {
    the_post();
    $post_id = get_the_ID();

    $books = get_books_for_author_id( $post_id );
    if ( 0 < count($books) ) {
        echo '<p>This author has published ' . count($books) . ' books:</p><ul>';
        foreach ( $books as $book ) {
            $book_link = get_permalink( $book->ID );
            echo '<li><a href="' . $book_link . '">' . $book->post_title . '</a></li>';
        }
        echo '</ul>';
    } else {
        echo '<p>This author has no published books on record.</p>';
    }

    $author_data = get_author_extra_data_for_author_id( $post_id );
    if ( array_key_exists('favorite_color', $author_data ) ) {
        echo '<p>The authors favorite color is ' . $author_data['favorite_color'] . '</p>';
    }

}

Getting Book Authors

Below is a helper function to pull all authors given a book id.

function get_authors_for_book_id( $book_id = 0 ) {
    $authors = array();

    if ( 0 < $book_id ) {
        $author_ids = get_post_meta( $book_id, '_author_id', false );
        if ( is_array( $author_ids ) && 0 < count($author_ids) ) {
            $args = array(
                'post_type' => 'author',
                'posts_per_page' => -1,
                'post__in' => $author_ids
            );
            $found = new \WP_Query( $args );
            if ( $found->have_posts() ) {
                while ( $found->have_posts() ) {
                    $authors[] = $found->next_post();
                }
            }
        }
    }
    return $authors;
}

To use get_authors_for_book_id, you might use it on a single book template or book archive template as follows:

while ( have_posts() ) {
    the_post();
    $post_id = get_the_ID();

    # book display might go here as normal in the loop

    # Adding author data.
    $authors = get_authors_for_book_id( $post_id );
    if ( 0 == count($authors) ) {
        echo '<p>This book has no known author...</p>';
    } else {
        echo '<p>Authors:</p><ul>';
        foreach ( $authors as $author_id => $data ) {
            $author = $data['post'];
            $author_info = get_author_extra_data_for_author_id( $author->ID );
            echo '<li><a href="' . get_permalink( $author->ID ) . '">' . $author->post_title . '</a>';
            if ( array_key_exists('favorite_color', $author_info) ) {
                echo '<br />Favorite color: ' . $author_info['favorite_color'];
            }
            echo '</li>';
        }
    }
}

End Results

You now have two post types with a many to many relationship and some helper functions to pull data where needed.

There is a lot of room for improvement in this code, but it should do the trick.

Leave a Comment