Relate two custom post type using meta box

I assume you would like to end up with something like this:
enter image description here

Code way

If you want to put this logic in your template/plugin, as you state in your question, there is quite a bit of coding.

You need to:

  • create custom post types
  • create metabox for seller
  • create content for metabox – that’s called “callback” in the meta_box docs
  • create logic that saves data from the meta box
  • create metabox for product
  • retrieve the data on front end

Create custom post types

I assume you already have this done, but let’s register custom post types product and seller manually here:

Products (‘product’)

/**
 * Post Type: Products.
 */
function se345571_register_cpt_product() {

  $labels = array(
    "name" => __( "Products", "mytheme_textdomain" ),
    "singular_name" => __( "Product", "mytheme_textdomain" ),
  );

  $args = array(
    "label" => __( "Products", "mytheme_textdomain" ),
    "labels" => $labels,
    "description" => "",
    "public" => true,
    "publicly_queryable" => true,
    "show_ui" => true,
    "delete_with_user" => false,
    "show_in_rest" => true,
    "rest_base" => "",
    "rest_controller_class" => "WP_REST_Posts_Controller",
    "has_archive" => false,
    "show_in_menu" => true,
    "show_in_nav_menus" => true,
    "exclude_from_search" => false,
    "capability_type" => "post",
    "map_meta_cap" => true,
    "hierarchical" => false,
    "rewrite" => array( "slug" => "product", "with_front" => true ),
    "query_var" => true,
    "supports" => array( "title", "editor", "thumbnail" ),
  );

  register_post_type( "product", $args );
}

add_action( 'init', 'se345571_register_cpt_product' );

Sellers (‘seller’)

/**
 * Post Type: Sellers.
 */
function se345571_register_cpt_seller() {

  $labels = array(
    "name" => __( "Sellers", "mytheme_textdomain" ),
    "singular_name" => __( "Seller", "mytheme_textdomain" ),
  );

  $args = array(
    "label" => __( "Sellers", "mytheme_textdomain" ),
    "labels" => $labels,
    "description" => "",
    "public" => true,
    "publicly_queryable" => true,
    "show_ui" => true,
    "delete_with_user" => false,
    "show_in_rest" => true,
    "rest_base" => "",
    "rest_controller_class" => "WP_REST_Posts_Controller",
    "has_archive" => false,
    "show_in_menu" => true,
    "show_in_nav_menus" => true,
    "exclude_from_search" => false,
    "capability_type" => "post",
    "map_meta_cap" => true,
    "hierarchical" => false,
    "rewrite" => array( "slug" => "seller", "with_front" => true ),
    "query_var" => true,
    "supports" => array( "title", "editor", "thumbnail" ),
  );

  register_post_type( "seller", $args );
}

add_action( 'init', 'se345571_register_cpt_seller' );

Create metabox for product

/**
 * Adds a box to "advanced" part on the Seller edit screen.
 * - See the different screens defined in $screens array.
 */
function se345571_add_seller_meta_box() {

  $screens = array( 'seller' );

  foreach ( $screens as $screen ) {

    // https://codex.wordpress.org/Function_Reference/add_meta_box - add_meta_box(), see for further params
    add_meta_box(
      'product_settings_box',                           // HTML 'id' attribute of the edit screen section
      __( 'Product settings', 'mytheme_textdomain' ),   // Title of the edit screen section, visible to user
      'se345571_product_settings_meta_box_callback',    // Function that prints out the HTML for the edit screen section.
      $screen                                           // Which writing screen ('post','page','dashboard','link','attachment','custom_post_type','comment')
    );

  }
}
add_action( 'add_meta_boxes', 'se345571_add_seller_meta_box' );

Here you can see, that add an action, which is called in add_meta_boxes – that means, when WordPress is building up the meta boxes for writing screen, it’s gonna call our custom se345571_add_seller_meta_box() function as well.

Create content for metabox

/**
 * Prints the box content.
 * 
 * @param WP_Post $post The object for the current post/page.
 */
function se345571_product_settings_meta_box_callback( $post, $box ) {

  // Add a nonce field so we can check for it later.
  wp_nonce_field( 'se345571_product_settings_meta_box_data', 'se345571_product_settings_meta_box_nonce' );

  /*
   * Use get_post_meta() to retrieve an existing value
   * from the database and use the value for the form.
   */
  $value = get_post_meta( $post->ID, '_product_settings', true );

  if ($value) {
    $product_settings = json_decode($value, true);
  }

  // Get available products so we can show them in select box
  $args = [
    'post_type' => 'product',
    'numberposts' => -1,
    'orderby' => 'id',
    'order' => 'ASC'
  ];

  $products = new WP_Query($args);

  // As you can see, i have 5 product fields, this can be just about any number
  $max = 5;

  ?>
  <table>
    <?php for ($index = 0; $index < $max; $index++) : ?>
    <tr>
      <td>
        <label for="product-<?php echo $index + 1 ?>-product"><?php _e( 'Product #' . ($index + 1), 'mytheme_textdomain' )?></label>
      </td>
      <td>
        <?php $productindex = 0; ?>
        <select name="product_settings[<?php echo $index ?>][product_id]" id="product-<?php echo ($index + 1) ?>-product">
          <?php while($products->have_posts()) : $products->the_post(); $productindex++; ?>
            <option value="<?php the_ID() ?>" <?php echo (isset($product_settings[$index]['product_id']) && (int)$product_settings[$index]['product_id'] === get_the_ID()) ? 'selected' : '' ?>><?php the_title() ?></option>
          <?php endwhile; ?>
        </select>
      </td>
      <td>
        <label for="product-<?php echo $index + 1 ?>-price"><?php _e( 'Price', 'mytheme_textdomain' )?></label>
      </td>
      <td>
        <input 
          name="product_settings[<?php echo $index ?>][price]" 
          type="text" 
          class="components-text-control__input" 
          id="product-<?php echo ($index + 1) ?>-price"
          value="<?php echo isset($product_settings[$index]['price']) ? $product_settings[$index]['price'] : '' ?>">
      </td>
    </tr>
    <?php endfor; ?>
  </table>
  <?php

  // Don't forget about this, otherwise you will mess up with other data on the page
  wp_reset_postdata();

}

Okay, here it comes with quite a bit of code. As other people in this thread suggested, you might find useful to extend this logic with javascript (such as select2.js). You can write that directly to HTML output of this function.

Saving the data


/**
 * When the post is saved, saves our custom data.
 *
 * @param int $post_id The ID of the post being saved.
 */
function se345571_product_settings_meta_box_data( $post_id, $post ) {

  // Check if our nonce is set.
  if ( ! isset( $_POST['se345571_product_settings_meta_box_nonce'] ) ) {
    return;
  }

  // Verify that the nonce is valid.
  if ( ! wp_verify_nonce( $_POST['se345571_product_settings_meta_box_nonce'], 'se345571_product_settings_meta_box_data' ) ) {
    return;
  }

  // If this is an autosave, our form has not been submitted, so we don't want to do anything.
  if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
    return;
  }

  /* OK, it's safe for us to save the data now. */

  // Make sure that it is set.
  if ( ! isset( $_POST['product_settings'] ) ) {
    return;
  }

  // HERE STARTS THE ACTUAL FUNCTIONALITY

  // Sanitize user input.
  $product_settings = json_encode( $_POST['product_settings'] );

  // Update the meta field in the database.
  update_post_meta( $post_id, '_product_settings', $product_settings );

}

add_action( 'save_post', 'se345571_product_settings_meta_box_data', 10, 2 );

Here’s another hook, save_post and our custom logic to it. Basically, it’s 1:1 what’s found on Codex to the add_meta_box() function just swapped the POST field names.

Create metabox for product

You ask to display price the seller entered for different products, as i don’t have any frontend here, i will show in the product admin page. But the same logic can be applied to show it on frontend for example.


/**
 * Adds a box to "advanced" part on the Seller edit screen.
 * - See the different screens defined in $screens array.
 */
function se345571_add_product_meta_box() {

  $screens = array( 'product' );

  foreach ( $screens as $screen ) {

    // https://codex.wordpress.org/Function_Reference/add_meta_box - add_meta_box(), see for further params
    add_meta_box(
      'product_settings_box',                           // HTML 'id' attribute of the edit screen section
      __( 'Seller prices', 'mytheme_textdomain' ),      // Title of the edit screen section, visible to user
      'se345571_seller_prices_meta_box_callback',       // Function that prints out the HTML for the edit screen section.
      $screen                                           // Which writing screen ('post','page','dashboard','link','attachment','custom_post_type','comment')
    );

  }
}
add_action( 'add_meta_boxes', 'se345571_add_product_meta_box' );

/**
 * Prints the box content.
 * 
 * @param WP_Post $post The object for the current post/page.
 */
function se345571_seller_prices_meta_box_callback( $post, $box ) {

  $product_id = get_the_ID();

  $args = [
    'post_type' => 'seller',
    'numberposts' => -1,
    'meta_query' => [
      [
        'key' => '_product_settings',
        'value' => '"product_id":"' .$product_id. '"',
        'compare' => 'LIKE',
      ]
    ]
  ];

  $sellers = new WP_Query($args);

  while($sellers->have_posts()) : $sellers->the_post();

    $seller_prices = json_decode(get_post_meta( get_the_ID(), '_product_settings', true ), true);

    $seller_prices_for_product = array_filter($seller_prices, function($element) use ($product_id) { 
      if (isset($element['product_id'])) {
        return (int)$element['product_id'] === $product_id;
      }
      return false;
    });

    foreach($seller_prices_for_product as $price) :
      ?>
      <p>
        <?php the_title(); ?> <?php _e('for', 'mytheme_textdomain'); ?> <?php echo $price['price'] ?>
      </p>
      <?php
    endforeach;

  endwhile;

  // Don't forget about this, otherwise you will mess up with other data on the page
  wp_reset_postdata();

}

Retrieving data

Finally when you need to grab your data on the frontend, you can go to template and use something like:

$value = get_post_meta( get_the_ID(), '_product_settings', true );
$product_settings = json_decode($value, true);

get_post_meta() uses your current post ID, key of the field you want to retrieve (optional) and whatever you want only one or all meta (optional – not set returns all custom fields).

This said, i’d actually go the plugin way (unless you are writing plugin yourself).

Plugin way

For cases like this, as other people pointed out, i’d pick Advanced Custom Fields plugin. Which can basically do the same, what is coded above.

Setting up the meta box is done via user interface.

Retrieving data

For retrieving data you use functions get_field() or the_field() – whatever you want to return the value or print the value right away (same naming convention as in WP all around).

get_field('your_field_name');

Sources and further reading

http://themefoundation.com/wordpress-meta-boxes-guide/
https://codex.wordpress.org/Function_Reference/add_meta_box
http://www.smashingmagazine.com/2011/10/04/create-custom-post-meta-boxes-wordpress/