Relationships between Post Types in WordPress

Written by on in Chiang Mai, Thailand.

This article covers the theory behind hierarchical relationships between post types in WordPress and how to set them up and use them.

I’m currently working on a database of all universities and their study programs in Austria for UNI.at. For that I registered my custom post type uni and study and used the Advanced Custom Fields plugin to attach more complex metadata like location fields, images or videos. There are also two custom taxonomies for the regional location (city/state) of the university and the branch of science the study covers (e.g. natural or social sciences).

After the setup, both post types existed next to each other but I had no connection between them. A study program always belongs to the university that teaches it and of course a university profile should display what studies there are1.

relationship

The Solution: Post Hierarchies

It hit me when I looked at the columns of the posts table and saw that by default there is a post_parent field for every post object in WordPress.

db

A quick reminder: WordPress treats most of its content as posts. Pages? They are basically posts with fewer features. Media attachments? Just posts with a MIME type. Menu items? Just posts.

Whenever I looked at hierarchical parameter when registering post types I assumed they must be of the same type.

hierarchical

(boolean) (optional) Whether the post type is hierarchical (e.g. page). Allows Parent to be specified. The ‘supports’ parameter should contain ‘page-attributes’ to show the parent select box on the editor page.
Default: false

But actually WordPress does not care what post type the referenced post ID in the parent field is. In fact, media files are attached to the posts to which they were uploaded to (hence the name attachment) by setting a parent-children relationship between the post and the media file. You see the concept is even used for one of the most essential functions of the WordPress core. So let’s go and use that too! I thought to myself.

Register the Post Type Correctly

I set the hierarchical value toTRUE when I registered the child post type (in my case the study). I also added `page-attributes to the array of supported features as the codex recommends. This is how it looked like in my code repository:

<?php
$args = array(
        'labels' => $labels,
        'public'             => true,
        'publicly_queryable' => true,
        'show_ui'            => true,
        'show_in_menu'       => true,
        'query_var'          => true,
        'rewrite'            => array( 'slug' => 'studieren', 'with_front' => false ),
        'capability_type'    => 'page',
        'has_archive'        => true,
        'hierarchical'       => true,
        'menu_position'      => 5,
        'supports'           => array( 'title', 'custom-fields', 'page-attributes' ),
        'taxonomies'             => array( 'region', 'discipline' )
);
register_post_type( 'study', $args ); ?>

Reusing the Admin Dropdown

The hierarchical value is used in line 638 of wp-admin/includes/meta-boxes.php:

<?php
function page_attributes_meta_box($post) {

        $post_type_object = get_post_type_object($post->post_type);

        if ( $post_type_object->hierarchical ) {
                $dropdown_args = array(
                        'post_type'        => $post->post_type,
                        'exclude_tree'     => $post->ID,
                        'selected'         => $post->post_parent,
                        'name'             => 'parent_id',
                        'show_option_none' => __('(no parent)'),
                        'sort_column'      => 'menu_order, post_title',
                        'echo'             => 0,
                );

                $dropdown_args = apply_filters( 'page_attributes_dropdown_pages_args', $dropdown_args, $post );
                $pages = wp_dropdown_pages( $dropdown_args );
                if ( ! empty($pages) ) {?>

The page_attributes_meta_box function renders the dropdown in the attributes meta box of the post edit admin screen ($post contains that post). An if statement then checks whether the post is hierarchical and does not continue rendering the dropdown if that is falsy.

As the above snippet of meta-boxes.php shows, $dropdown_args is then prepared with a bunch of default arguments that all look good except the one for post_type. This should show the universities not other study programs so I changed that using the filter page_attributes_dropdown_pages_args:

<?php
add_filter( 'page_attributes_dropdown_pages_args', 'study_attributes_dropdown_pages_args' );
function study_attributes_dropdown_pages_args( $dropdown_args, $post ) {
        if ( 'study' == $post->post_type ) {
                $dropdown_args['post_type'] = 'uni';
        }
        return $dropdown_args;
} ?>

That’s all needed to include the universities into the parent dropdown in the attributes meta box. If I had a lot of posts I could also add Select2 to add a search to the dropdown but as of the writing of this article, we only have 77 universities of Austria in our database and that number is not going to explode in the foreseeable future.

Quicktip: When you focus a dropdown you can press any key on your keyboard to select that value. It even works for multiple keys so whenever the checkout of an online store presents me a dropdown of a list of 200 alphabetically sorted countries, I just focus that dropdown and type „Ger“ to select „Germany“ from the list.

study

The Hierarchical Permalink

The great thing of this approach is that it comes with a free hierarchical permalink structure. An example in my case would be the study program Doktoratsstudium der Rechtswissenschaften that I assigned to the University of Graz:
http://www.uni.at/studieren/karl-franzens-universitaet-graz/dok-doktoratsstudium-der-rechtswissenschaften-dr-2/

There is nothing I needed to do to add the university name as a parent of the study in permalink (and frankly I wouldn’t know how to prevent this).
However, following this link displayed Page not found.

To debug that error I needed to look at what was WordPress was doing when it resolved that URL. I’m using Chrome Logger to write out the main WordPress query to the Chrome Developer Console in my local development environment. The code is in a MU-Plugin but could also be loaded in the functions.php:

<?php
if ( WP_LOCAL_DEV ) {

        add_action( 'template_redirect', 'log_query');
        function log_query(){
                global $wp_query;
                ChromePhp::log( $wp_query );
        }
} ?>

Note here that I’m automatically including ChromePHP in every file in my local PHP environment using this statement in my php.ini so I don’t have to include it manually for every project.

[User]
  auto_prepend_file = "~/Projekte/Development/lib/chromephp/ChromePhp.php"

The following video demonstrates how the query object can be inspected in the browser.

Inspecting the global $wp_query variable using Chrome Developer Tools

The logged query revealed two causes of the problem:

  1. The query included the university name in the value of the name parameter:
    university-name/study-name.
  2. The query included a duplicate of name called study.

Under „normal“ conditions name would not include the parent name and retrieve the correct post from the database. Apparently however this setup of multi-post-type hierarchy isn’t what WordPress expects for custom post types, so I needed to clean up the query arguments on pre_get_posts:

<?php
add_filter( 'pre_get_posts', 'study_query' );
function study_query( $query ) {

        // run this code only when we are on the public archive
        if( 'study' != $query->query_vars['post_type'] || ! $query->is_main_query() || is_admin() ) {
           return;
        }

        // fix query for hierarchical study permalinks
        if ( isset( $query->query_vars['name']) && isset( $query->query_vars['study'] ) ) {

                // remove the parent name
                $query->set( 'name', basename( untrailingslashit( $query->query_vars['name'] ) ));

                // unset this
                $query->set( 'study', null );
        }
} ?>

A word on SEO

As an result of the changes I made, the study programs are now still available under their „old“ URL as well: http://www.uni.at/studieren/dok-doktoratsstudium-der-rechtswissenschaften-dr-2/

I could fix that but I think it shouldn’t be a problem. I’m using the WordPress SEO Plugin by Yoast to display a canonical link element in the head of the page to point search engines to the correct address (the one that includes the university). In fact in my case it’s even better since I changed the URL addresses after the studies programs were already published and this way the old addresses continue to work.

Template Usage

A few examples how I use this approach in the template files of my theme.

item%402x

I use this snippet to quickly display the name and a link to the university before each entry in the study archive.

<?php if ( ! empty( $post->post_parent ) ) : ?>
        <h6 class="poster-title text-primary">
                <a href="<?php echo get_permalink( $post->post_parent ) ?>">
                        <?php echo get_the_title( $post->post_parent ) ?>
                </a>
        </h6>
<?php endif ?>

Display the number of study programs the university has in the archive:

<?php
$args = array(
        'post_parent'   => $post->ID,
        'fields'                => 'ids',
        'post_type'             => 'study'
);
$studies = get_children( $args );
if ( ! empty( $studies ) ) : ?>
        <span class="unicon-uni-fh"></span> <b><?php echo count( $studies ) ?></b> Studien
<?php endif ?>
uni

On the detail pages of the universities I list the scientific branches (discipline) with the number of study programs and use the get_children function.

<?php
/**
* STUDIES
* Returns study programs of this university
*/

$args = array(
        'post_type'     => 'study',
        'post_parent'   => $post->ID,
        'post_status'   => 'publish',
        'fields'                => 'ids'
);
$studies            = get_children( $args );
$disciplines        = get_terms( 'discipline' );
$study_archive_link = add_query_arg( array( 'post_parent' => $post->ID ), get_post_type_archive_link( 'study' ));

if ( ! is_wp_error( $disciplines ) ) : ?>

<div class="panel panel-default">
        <div class="panel-heading panel-toggle">
                <h3 class="panel-title" data-target="#panelStudies">
                        <span class="unicon-uni-fh"></span> Studien
                </h3>
        </div>
        <div class="panel-collapse" id="panelStudies">
                <div class="panel-body">
                        <div class="list-group clearfix">

                        <?php foreach ( $disciplines as $discipline ) :

                                $term_link = get_term_link( $discipline, 'discipline' );
                                if ( is_wp_error( $term_link ) ) {
                                         continue;
                                }
                                $discipline_posts               = get_children( wp_parse_args( array( 'discipline' => $discipline->slug ), $args) );
                                $count_discipline_posts = count( $discipline_posts );
                                $archive_link                   = add_query_arg( array( 'discipline' => $discipline->slug ), $study_archive_link );
                                if ( 0 == $count_discipline_posts ) {
                                         continue;
                                }
                         ?>

                                <a href="<?php echo $archive_link ?>" class="list-group-item">
                                        <?php echo $discipline->name ?> <span class="badge"><?php echo $count_discipline_posts ?></span>
                                </a>

                        <?php endforeach ?>

                                <a href="<?php echo $study_archive_link ?>" class="list-group-item text-center" title="alle Studien dieser Uni anzeigen">
                                        <b>Alle <?php echo count( $studies ) ?> Studien anzeigen</b>
                                </a>
                        </div>
                </div>
        </div>
</div>
<?php endif ?>

To display a link to the archive that lists all universities study programs I just added the ID of the university as an argument to the post_parent parameter to the URL of the study archive.
To make this work I needed to register that URL parameter as a query variable and extended the pre_get_posts filter:

<?php
add_filter( 'query_vars', 'add_study_query_vars' )
function add_study_query_vars( $vars ){
        $vars[] = 'post_parent';
        return $vars;
}

add_filter( 'pre_get_posts', 'study_query' );
function study_query( $query ) {

        // run this code only when we are on the public archive
        if( 'study' != $query->query_vars['post_type'] || ! $query->is_main_query() || is_admin() ) {
           return;
        }

        // fix query for hierarchical study permalinks
        if ( isset( $query->query_vars['name']) && isset( $query->query_vars['study'] ) ) {

                // remove the parent name
                $query->set( 'name', basename( untrailingslashit( $query->query_vars['name'] ) ));

                // unset this
                $query->set( 'study', null );
        }

        // filter studies by their university
        if ( $query->query_vars['post_parent'] ) {
                $query->set( 'post_parent', $query->query_vars['post_parent'] );
        }
} ?>

Example URL: http://www.uni.at/studieren/?post_parent=1432

I mentioned in the introduction that I’m using a regional taxonomy to group universities and study programs into city and state clusters. This allows everyone to query only for results in their home area.
However a study program will always have the same region as the university it is a child of so maintaining this data in two places feels like a waste of time when it can be automated instead:

<?php
// executes this action whenever a study post is saved
add_action( 'save_post', 'sync_study_region_from_uni' );

function sync_study_region_from_uni( $study_id ) {

        // make sure that this runs only when a study is saved or updated (or this function is called directly)
        if ( (isset($_POST['post_type']) && 'study' != $_POST['post_type'] ) || wp_is_post_revision( $study_id )) {
                return;
        }

        $university_id = get_post_field( 'post_parent', $study_id );

        if ( ! empty( $university_id ) ) {

                // get the region(s) of the university
                $university_regions = wp_get_object_terms( $university_id, 'region', array( 'fields' => 'ids' ) );

                // prepare arguments for wp_set_object_terms
                $university_regions = array_unique( array_map( 'intval', $terms));

                // sets the university regions to the study
                $set_study_regions = wp_set_object_terms( $study_id, $uni_regions, 'region', true );

                return ! is_wp_error( $set_study_regions );
        }

        return false;
} ?>

Now the studies copy their regions from the university that they belong to.

Appendix: Alternative Solutions

This article ends here but I also wanted loose a few words about alternative implementations of a hierarchical post structure that I considered.

acf

ACF Relationship Field

My beloved swiss army knife Advanced Custom Fields includes a relationship field. Its pretty UI includes a ajaxified live search and multiple post selection. In many cases this is exactly what I’d want but in this case it was unfit for two reasons:

  1. I’m not looking for multiple posts to be selected - just one, the university.
  2. ACF stores the value of the post selection as an array in the database.

The first reason can be omitted since you can limit the maximum number of posts to be selected of the relationship field but the second one is a bit of a pain when you want to query based on the value.

To query all study programs of a university you always need a meta query:

<?php
$args = array(
        'post_type'     => 'study',
        'meta_query'    => array(
                array(
                        'key'           => 'uni',
                        'value'         => (string) $post->ID,
                        'compare'       => 'LIKE'
                )
        )
);
$query = new WP_Query( $args ); ?>

To show the university of the study program you would write:

<?php
$uni = get_field( 'uni' );
$args = array(
        'post_type' => 'uni',
        'p'             => $uni ? $uni[0]->ID : 0
);
$query = new WP_Query( $args ); ?>

Both examples show that it can be done but the fact that the ID is wrapped in the first element of an Array bloats the code every time I use it. This doesn’t event cover how updating a value to the meta field would look like.

A meta query in the first example requires WordPress to join two tables to fulfill the request ($wpdb->posts + $wpdb->postmeta). And get_field( 'uni’ ) returns a complete post object from the database (you could prevent that though, by setting the 3rd argument of the function to FALSE though).

You can see how the relationship field doesn’t really fit the requirement of this case.

A Custom Taxonomy

Another solution would be setup a custom taxonomy instead of post type for the universities. This would allow for N:N relationships (which I don’t need) and technically you can add custom fields to terms as well and style their templates to make them look like posts.

But again if you think about it a university should be a post object nothing else. It might just seem like idealism but in my experience you should not hack around essential concepts of your application. In the end you will save yourself a lot of headaches if you just stick to the native tools WordPress has to offer as close as possible.


  1. I am talking about 1 : N relationships