Linking together two different custom post types

I hope someone can help out with this as it is very frustrating…

I am trying to build a property site in WordPress and would like to link two custom post types together –

One would be development areas and the other would be individual properties within that development area

I understand that I can create a CPT called ‘Developments’ and another called ‘Properties’ but how would I go about linking them together?.. for example if I created a property and tried to attach it to a development how would this work if they are separate custom post types?

I read a similar question – How to connect different CPTs together? and the answer by Scuba Kay was almost what I needed but I don’t know how you would query a certain property which belongs to a certain Development

Thanks in advance!

3 Answers
3

If I understand what you are doing, you are looking to set up a one to many relationship between development areas and properties.

Here is a class that, if loaded and then instantiated, will:

  • create a meta box for a post type named development_area which contains a check box list of all available properties so that one can update the relationship between the two easily.
  • create a meta box for a post type named property which contains a select box to choose the related development area for a property.
  • ensure that there is a 1 to many relationship between development areas and properties, meaning that a development area can be related to many different properties but any property will only have one related development area.

Note:

This code illustrates how to do this. While I have checked it for stupid errors and double checked the logic, you may well have to tweak things as I’ve not tested it at runtime.
I do this all the time and it works very nicely.

Following the class is an example of how to make it work and then a couple of examples on how to use the relationships that the class sets up.

class One_To_Many_Linker {

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

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

Setting up the meta boxes and save functionality

    protected function do_initialize() {

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

        add_action(
            "add_meta_boxes_for_development_area",
            array( $this, 'setup_development_area_boxes' )
        );

        add_action(
            "add_meta_boxes_for_property",
            array( $this, 'setup_property_boxes' )
        );

    }

Creating our needed meta box
Other metaboxes could easily be set up here as well.

    # Development area post type
    # - this post type can have multiple related properties posts
    public function setup_development_area_boxes( \WP_Post $post ) {
        add_meta_box(
            'area_related_properties_box',
            __('Related Properties', 'language'),
            array( $this, 'draw_area_related_properties_box' ),
            $post->post_type,
            'advanced',
            'default'
        );
    }

Drawing the Related Properties
This code draws out the related properties as a series of check boxes.

    public function draw_area_related_properties_box( \WP_Post $post ) {

        $all_properties = $this->get_all_of_post_type( 'property' );

        $linked_property_ids = $this->get_linked_property_ids( $post->ID );

        if ( 0 == count($all_properties) ) {
            $choice_block = '<p>No properties currently in system.</p>';
        } else {
            $choices = array();
            foreach ( $all_properties as $property ) {
                $checked = ( in_array( $property->ID, $linked_property_ids ) ) ? ' checked="checked"' : '';

                $display_name = esc_attr( $property->post_title );
                $choices[] = <<<HTML
<label><input type="checkbox" name="property_ids[]" value="{$property->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 lists of posts
This grabs all posts of a specific type.
If you use sticky posts, you will want to unset the sticky flag in the args argument.

    # 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 linked property ids for a development area

Given a development area id, this will return an array of all linked property post ids.

    protected function get_linked_property_ids( $area_id = 0 ) {
        $ids = array();
        if ( 0 < $area_id ) {
            $args = array(
                'post_type' => 'property',
                'posts_per_page' => -1,
                'order' => 'ASC',
                'orderby' => 'title',
                'meta_query' => array(
                    array(
                        'key' => '_development_area_id',
                        'value' => (int)$area_id,
                        'type' => 'NUMERIC',
                        'compare' => '='
                    )
                )
            );
            $results = new \WP_Query( $args );
            if ( $results->have_posts() ) {
                while ( $results->have_posts() ) {
                    $item = $results->next_post();
                    $ids[] = $item->ID;
                }
            }
        }
        return $ids;
    }

Setting up the Property meta boxes

You could easily add more meta boxes here if wanted.

    # Post type metabox setup
    public function setup_property_boxes( \WP_Post $post ) {
        add_meta_box(
            'property_linked_area_box',
            __('Related Development Area', 'language'),
            array( $this, 'draw_property_linked_area_box' ),
            $post->post_type,
            'advanced',
            'default'
        );
    }

Drawing the Property editor meta box

This draws a select element (drop down box) that lets the user choose which development area the property relates to.
We use a select box to ensure that only one property can be specified, but a list of radio boxes would work also.

    public function draw_property_linked_area_box( \WP_Post $post ) {

        $all_areas = $this->get_all_of_post_type('development_area');

        $related_area_id = $this->get_property_linked_area_id( $post->ID );

        if ( 0 == $all_areas ) {
            $choice_block = '<p>No development areas to choose from yet.</p>';
        } else {
            $choices = array();
            $selected = ( 0 == $related_area_id )? ' selected="selected"':'';
            $choices[] = '<option value=""' . $selected . '> -- None -- </option>';
            foreach ( $all_areas as $area ) {
                $selected = ( $area->ID == (int)$related_area_id ) ? ' selected="selected"' : '';

                $display_name = esc_attr( $area->post_title );
                $choices[] = <<<HTML
<option value="{$area->ID}" {$selected}>{$display_name}</option>
HTML;

            }
            $choice_list = implode("\r\n" . $choices);
            $choice_block = <<<HTML
<select name="development_area_id">
{$choice_list}
</select>
HTML;

        }

        wp_nonce_field(
            "updating_{$post->post_type}_meta_fields",
            $post->post_type . '_meta_nonce'
        );

        echo $choice_block;
    }

The method of linking

Note that we establish our links by setting a _development_area_id key on each property.

  • development areas can query properties with this key to display them
  • properties can pull their own meta or have their query filtered to pull development data

    protected function get_property_linked_area_id( $property_id = 0 ) {
        $area_id = 0;
        if ( 0 < $property_id ) {
            $area_id = (int) get_post_meta( $property_id, '_development_area_id', true );
        }
        return $area_id;
    }
    

Saving the meta data

We take pains to only save when necessary and proper. See code comments.

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

        $do_save = true;

        $allowed_post_types = array(
            'development_area',
            'property'
        );

        # 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 "development_area":
                    $this->handle_development_area_meta_changes( $post_id, $_POST );
                    break;
                case "property":
                    $this->handle_property_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;
    }

Updating Development Properties

We read the list of checked property post types, grab the list of currently related property post types, and then use those lists to determine what to update.

Notice: We are updating meta data on property post types here, not on our edited development area.

    # Development areas can link to multiple properties
    # but each property can only link to a single development area
    protected function handle_development_area_meta_changes( $post_id = 0, $data = array() ) {

        # Get the currently linked property ids for this development area
        $linked_property_ids = $this->get_linked_property_ids( $post_id );

        # Get the list of property ids checked when the user saved changes
        if ( array_key_exists('property_ids', $data) && is_array( $data['property_ids'] ) ) {
            $chosen_property_ids = $data['property_ids'];
        } else {
            $chosen_property_ids = array();
        }

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

        if ( 0 < count( $chosen_property_ids ) ) {
            # The user chose at least one property to link to
            if ( 0 < count( $linked_property_ids ) ) {
                # We already had at least one property linked

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

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

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

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

        if ( 0 < count($to_add) ) {
            foreach ( $to_add as $property_id ) {
                # This will overwrite any existing value for the linking key
                # to ensure we maintain only one dev area linked by each property.
                update_post_meta( $property_id, '_development_area_id', $post_id );
            }
        }

        if ( 0 < count( $to_remove ) ) {
            foreach ( $to_remove as $property_id ) {
                # This will delete all existing linked areas for the property
                # to ensure we only ever have one linked area per property
                delete_post_meta( $property_id, '_development_area_id' );
            }
        }
    }

Saving our Property Changes

Since our meta key is on each property, we simply update our meta data if necessary.
As reading is almost always faster in mysql than writing, we only update if absolutely necessary.

    # Properties only relate to a single development area
    protected function handle_property_meta_changes( $post_id = 0, $data = array() ) {

        # Get any currently linked development area
        $linked_area_id = $this->get_property_linked_area_id( $post_id );
        if ( empty($linked_area_id) ) {
            $linked_area_id = 0;
        }

        if ( array_key_exists( 'development_area_id', $data ) && !empty($data['development_area_id'] ) ) {
            $received_area_id = (int)$data['development_area_id'];
        } else {
            $received_area_id = 0;
        }

        if ( $received_area_id != $linked_area_id ) {
            # This will overwrite any and all existing copies of our meta key
            # so we can ensure we only have one linked area per property
            update_post_meta( $post_id, '_development_area_id', $received_area_id );
        }
    }
}

How To Use The Class

Provided you load the class in your theme functions file or in a plugin, you can use the following to make things work:

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

Some Use Cases Follow
Below, I’ve provided a couple of front end use cases.

  • Displaying all properties for the current development area
  • Displaying the development area for a property on an archive or single property

Showing all properties related to the currently displayed development area

global $wp_query;
$area_id = $wp_query->get_queried_object_id();
$args = array(
    'post_type' => 'property',
    'posts_per_page' => -1,
    'meta_query' => array(
        array(
            'key' => '_development_area_id',
            'value' => $area_id,
            'compare' => '=',
            'type' => 'NUMERIC'
        )
    )
);
$properties = new \WP_Query( $args );
if ( $properties->have_posts() ) {
    while( $properties->have_posts() ) {
        $property = $properties->next_post();
        # do something with the property
        $property_link = get_permalink( $property->ID );
        $property_name = esc_attr( $property->post_title );
        echo '<a href="' . $property_link . '">' . $property_name . '</a>';
    }
}

Showing Linked Development Areas

Method 1: Grabbing the post meta, loading the area, and using the data

  • works on pages where is_singular(‘property’) is true
  • works on pages where is_post_type_archive(‘property’) is true
global $post;
while ( have_posts() ) {
    the_post();
    $post_id = get_the_ID(); # could use $post->ID

    $dev_area_id = get_post_meta( $post_id, '_development_area_id', true);
    if ( !empty( $dev_area_id ) ) {
        $development_area = get_post( $dev_area_id );
        # do something...
        $dev_area_link = get_permalink ( $development_area->ID );
        $dev_area_title = $development_area->post_title;
        $dev_area_content = $development_area->post_content;
        echo '<a href="' . $dev_area_link . '">' . $dev_area_title . '</a><br />' . $dev_area_content;
    }
}

Method 2: Using a query filter

  • works on pages where is_singular(‘property’) is true
  • works on pages where is_post_type_archive(‘property’) is true

Notice that this avoids having to pull the post type meta and doing an extra query for the development area data.
As you show more properties on a single page, this saves you more and more processing power.

Where to put the following code:

  • In a new plugin that you create recommended
  • In your theme functions.php file
add_filter('posts_clauses', 'do_my_maybe_modify_queries', 10, 2);
function do_my_maybe_modify_queries( $pieces, \WP_Query $wp_query ) {

    if ( !is_admin() && $wp_query->is_main_query() ) {
        # We are not in the admin panels and we are the primary query for the template

        if ( array_key_exists('post_type', $wp_query->query_vars) ) {
            # A post type of some kind was queried.
            # Grab it as an array of the types specified
            $value = $wp_query->query_vars['post_type'];
            if ( !is_array( $value ) ) {
                if ( empty( $value ) ) {
                    $post_types = array();
                } else {
                    $post_types = array( $value );
                }
            } else {
                $post_types = $value;
            }

            if ( in_array('property', $post_types) ) {
                # We were asked for a property
                if ( $wp_query->is_post_type_archive || $wp_query->is_singular ) {
                    # Showing the property post type archive or a singular property.
                    # We want to add our development area id, title, and content to the returned fields
                    global $wpdb;

                    # Link the development post to each property through its postmeta key
                    # Since there is only 1 development area per property, this works fine
                    $pieces['join'] .= <<<SQL
 LEFT JOIN {$wpdb->prefix}postmeta AS dev_pm ON {$wpdb->prefix}posts.ID = dev_pm.post_id AND dev_pm.meta_key = '_development_area_id'
    LEFT JOIN {$wpdb->prefix}posts AS dev_post ON dev_post.ID = dev_pm.meta_value 
SQL;

                    # Add our wanted development post fields to those returned by the query
                    $pieces['fields'] .= ", IFNULL( dev_pm.meta_value, 0 ) as development_area_id";
                    $pieces['fields'] .= ", IFNULL( dev_post.post_title, '') as development_area_title";
                    $pieces['fields'] .= ", IFNULL( dev_post.post_content, '') as development_area_content";

                }
            }
        }
    }
    return $pieces;
}

With the above in place, you can now access the data as follows on a single property page or property archive.
I leave doing this for taxonomies related to properties as an exercise to the reader.

if ( have_posts() ) {
    global $post;
    while ( have_posts() ) {
        the_post();
        if ( property_exists( $post, 'development_area_id' ) ) {
            $dev_area_id = $post->development_area_id;
            $dev_area_title = $post->development_area_title;
            $dev_area_content = $post->development_area_content;
            $dev_area_link = get_permalink( $dev_area_id );
            echo '<a href="' . $dev_area_link . '">' . $dev_area_title . '</a><br />' . $dev_area_content;
        }
    }
}

Method 3: Filtering WP_Query

Like the filter method above, but usable for custom queries using WP_Query. Great if you want to write a short code or widget that displays a bunch of properties.

First, we create our filter (very similar to that shown on method 2)

function add_dev_data_to_wp_query( $pieces, \WP_Query $wp_query ) {
    global $wpdb;

    if ( !is_admin() && !$wp_query->is_main_query() ) {
        # Link the development post to each property through its postmeta key
        # Since there is only 1 development area per property, this works fine
        $pieces['join'] .= <<<SQL
 LEFT JOIN {$wpdb->prefix}postmeta AS dev_pm ON {$wpdb->prefix}posts.ID = dev_pm.post_id AND dev_pm.meta_key = '_development_area_id'
    LEFT JOIN {$wpdb->prefix}posts AS dev_post ON dev_post.ID = dev_pm.meta_value 
SQL;

        # Add our wanted development post fields to those returned by the query
        $pieces['fields'] .= ", IFNULL( dev_pm.meta_value, 0 ) as development_area_id";
        $pieces['fields'] .= ", IFNULL( dev_post.post_title, '') as development_area_title";
        $pieces['fields'] .= ", IFNULL( dev_post.post_content, '') as development_area_content";
    }
    return $pieces;
}

Next, we apply our filter before we create the query and remove it right afterwards to ensure we do not alter other queries.

$args = array(
    'post_type' => 'property',
    'posts_per_page' => -1
);

# Apply our filter
add_filter('posts_clauses', 'add_dev_data_to_wp_query', 10, 2);

# Run the query
$properties = new \WP_Query( $args );

# Remove our filter
remove_filter('posts_clauses', 'add_dev_data_to_wp_query', 10);

if ( $properties->have_posts() ) {
    while( $properties->have_posts() ) {
        $property = $properties->next_post();

        # Do stuff with your properties
        echo '<p>The property name is ' . $property->post_title . '</p>';

        # Do stuff with related development areas if that data is available
        if ( property_exists( $property, 'development_area_id' ) ) {
            echo '<p>Part of the development area named ' . $property->development_area_title . '</p>';
        }
    }
}

While it takes a little more work up front to do things like this, it provides a couple of nice benefits

  • You are doing just 1 query instead of 2 to 3 queries to display related information. This adds up fast.
  • Once you are comfortable with it, you can use with relationships to multiple objects which will save you multiple extra queries each
  • Using a variation on this, you can do the same to add titles (and view and edit links) to the editor listing columns. For example with the above scenario you might list the development for each property in the properties list in the admin.

Of course, this is already long enough.

Leave a Comment