Chapter 5. Custom Post Types, Post Metadata, and Taxonomies

CPTs are what really makes WordPress a content management system. With them, you can quickly build out custom functionality and store data in a consistent way.

Default Post Types and CPTs

With a default installation of WordPress, you have several post types already being used. The post types you may be most familiar with are pages and posts, but there are a few more. These post type values are all stored in the database wp_posts table, and they all use the post_type field to separate them.

User Requests

Since WordPress 4.9, administrators have been able to manage personal data exports or personal data deletions on behalf of users.1 You can find the screen to manage personal data exports and deletions on the Tools menu in the dashboard. These requests are stored in a custom post of type user_request. See more about how these requests are handled in the WordPress reference for the wp_create_user_request() function.

Defining and Registering CPTs

Just like the default WordPress post types, you can create your own CPTs to manage any data you need, depending on what you are building. Every CPT is really just a post used differently. You could register a CPT for a dinner menu at a restaurant, for cars for an auto dealer, for people to track patient information and documents at a doctor’s office, or for pretty much anything you can think of. No, really, any type of content you can think of can be stored as a post with attached files, custom metadata, and custom taxonomies.

In our SchoolPress example, we are going to be building a CPT for managing homework assignments on a teacher’s website. Our teacher wants to make a post of some kind where they can add assignments and their students can find them on the class website. The teacher would also like to be able to upload supporting documents and have commenting available in case any students have questions. A CPT sounds in order, doesn’t it?

We can store this information the same way posts are dealt with and display them to the end user in the theme using the same wp_query loop we would with posts.

register_post_type( $post_type, $args );

You can register a CPT by using the function register_post_type(). And in most cases, you’ll register your CPT in your theme’s functions.php file or in a custom plugin file. This function expects two parameters—the name of the post type you are creating and an array of arguments:

$post_type

The name of your CPT; in our example, our CPT name is “homework.” This string must be no longer than 20 characters and can’t have capital letters, spaces, or any special characters except a hyphen or an underscore. If you are making a plugin for public consumption, you will want to use a prefix on your CPT’s name to avoid conflicts if another plugin uses the same name.

$args

This is an array of many different arguments that will dictate how your CPT will be set up.

Following is a list of all available arguments and what they are used for:

label

The display name of your post type. In our example, we use “Homework.”

labels

An optional array of labels to use for describing your post type throughout the user interface:

name

The plural display name of your post type. This will overrides the label argument.

singular_name

The singular name for any particular post. This defaults to the name if not specified.

add_new

Defaults to the string “Add New.”

add_new_item

Defaults to “Add New Post.”

edit_item

Defaults to “Edit Post.”

new_item

Defaults to “New Post.”

view_item

Defaults to “View Post.”

search_items

Defaults to “Search Posts.”

not_found

Defaults to “No Posts Found.”

not_found_in_trash

Defaults to “No posts found in Trash.”

parent_item_colon

Defaults to “Parent Page:” and is only used on hierarchical post types.

all_items

Defaults to “All Posts.”

menu_name

The menu name for the post type, usually the same as label or labels->name.

description

An optional string that describes your post type.

publicly_queryable

An optional Boolean that specifies whether queries on your post type can be run on the frontend or theme of your application. By default, publicly_queryable is turned on.

exclude_from_search

An optional Boolean that specifies whether your post type posts can be queried and displayed in the default WordPress search results. This is off by default so that your posts will be searchable.

capability_type

An optional string or array. If not specifically defined, capability_type will default to post. You can pass in a string of an existing post type, and the new post type you are registering will inherit that post type’s capabilities. You can also define your own capability type, which will set default capabilities for your CPT for reading, publishing, editing, and deleting. You can also pass in an array if you want to use different singular and plural words for your capabilities. For example, you can just pass in the string “homework” since the singular and plural forms for “homework” are the same, but you would pass in an array like array( 'submission', 'submissions' ) when the forms are different.

capabilities

An optional array of the capabilities of the post type you are registering. You can use this instead of capability_type if you want more granular control over the capabilities you are assigning to your new CPT.

There are two types of capabilities: meta and primitive. Meta capabilities are tied to specific posts, whereas primitive capabilities are more general purpose. In practice, this means that when checking if a user has a meta capability, you must pass in a $post_id parameter:

//meta capabilities are related to specific posts
if(current_user_can("edit_post", $post_id))
{
  //the current user can edit the post with ID = $post_id
}

Unlike meta capabilities, primitive capabilities aren’t checked against a specific post:

//primitive capabilities aren't related to specific posts
if(current_user_can("edit_posts"))
{
  //the current user can edit posts in general
}

The capabilities that can be assigned to your custom post type are as follows:

edit_post

A meta capability for a user to edit a particular post.

read_post

A meta capability for a user to read a particular post.

delete_post

A meta capability for a user to delete a particular post.

edit_posts

A primitive capability for a user to be able to create and edit posts.

edit_others_posts

A primitive capability for a user to be able to edit others’ posts.

publish_posts

A primitive capability for a user to be able to publish posts.

read_private_posts

A primitive capability for a user to be able to read private posts.

read

A primitive capability for a user to be able to read posts.

delete_posts

A primitive capability for a user to be able to delete posts.

delete_private_posts

A primitive capability for a user to be able to delete private posts.

delete_published_posts

A primitive capability for a user to be able to delete posts.

delete_others_posts

A primitive capability for a user to be able to delete other people’s posts.

edit_private_posts

A primitive capability for a user to be able to edit private posts.

edit_published_posts

A primitive capability for a user to be able to publish posts.

map_meta_cap

Whether to use the internal default meta capability handling (capabilities and roles are covered in Chapter 6). Defaults to false. You can always define your own capabilities using capabilities; but if you don’t, setting map_meta_cap to true will make the following primitive capabilities be used by default or in addition to using capability_type: read, delete_posts, delete_private_posts, delete_published_posts, delete_others_posts, edit_private_posts, and edit_published_posts.

hierarchical

An optional Boolean that specifies whether a post can be hierarchical and have a parent post. WordPress pages are set up like this so you can nest pages under other pages. The hierarchical argument is turned off by default.

public

An optional Boolean that specifies if a post type is supposed to be used publicly or not in the backend or frontend of WordPress. By default, this argument is false; so without including this argument and setting it to true, you couldn’t use this post_type in your theme. If you set public to true, it automatically sets exclude_from_search, publicly_queryable and show_ui_nav_menus to true unless otherwise specified.

Most CPTs will be public so they are shown on the frontend or available to manage through the WordPress dashboard. Other CPTs (like the default Revisions CPT) are updated behind the scenes based on other interactions with your app and would have public set to false.

rewrite

An optional Boolean or array used to create a custom permalink structure for a post type. By default, this is set to true, and the permalink structure for a custom post is /post_type/post_title/. If set to false, no custom permalink would be created. You can completely customize the permalink structure of a post by passing in an array with the following arguments:

slug

Defaults to the post_type but can be any string you want. Remember not to use the same slug in more than one post type because they must be unique.

with_front

Whether or not to prepend the “front base” to the front of the CPT permalink. If set to true, the slug of the “front page” set on the Settings → Reading page of the dashboard will be added to the permalink for posts of this post type.

feeds

Boolean that specifies whether a post type can have an RSS feed. The default value of this argument is set to the value of the has_archive argument. If feeds is set to false, no feeds will be available.

pages

Boolean that turns on pagination for a post type. If true, archive pages for this post type will support pagination.

ep_mask

EP, or endpoints, can be very useful. With this argument you assign an endpoint mask for a post type. For instance, we could set up an endpoint for a post type of homework called “pop-quiz.” The permalink would look like /homework/post-title/pop-quiz/. In MVC terminology, a CPT is similar to a module, and endpoints can be thought of as different views for that module. (We cover endpoints and other rewrite functions in Chapter 7.)

has_archive

An optional Boolean or string that specifies whether a post type can have an archive page. By default this argument is set to false, so you will want to set it to true if you would like to use it in your theme. The archive-<post_type>.php file in your theme will be used to render the archive page. If that file is not available, the archive.php or index.php file will be used instead.

query_var

An optional Boolean or string that sets the query_var key for the post type. This is the name of your post type in the database and used when writing queries to work with this post type. The default value for this argument is set to the value of post_type argument. In most cases you wouldn’t need your query_var and your post_type to be different, but you can imagine a long post type name like directory_entry for which you would want to use a shorter slug like “dir.”

supports

An optional Boolean or array that specifies what meta box features will be made available on the new post or edit post page. By default, an array with the arguments of title and editor is passed in. Following is a list of all available arguments (to use one of these features with your CPT, be sure it’s included in the supports array):

  • title

  • editor

  • comments

  • revisions

  • trackbacks

  • author

  • excerpt

  • page-attributes

  • thumbnail

  • custom-fields

  • post-formats

register_meta_box_cb

An optional string that allows you to provide a custom callback function for integrating your own custom meta boxes.

permalink_epmask

An optional string for specifying which endpoint types you would like to associate with a custom post type. The default rewrite endpoint bitmask is EP_PERMALINK. For more information on endpoints, see Chapter 7.

taxonomies

An optional array that specifies any built-in (categories and tags) or custom registered taxonomies you would like to associate with a post type. By default, no taxonomies are referenced. For more on taxonomies, see “Creating Custom Taxonomies”.

show_ui

An optional Boolean that specifies whether the basic post UI will be made available for a post type in the backend. The default value is set to the value of the public argument. If show_ui is false, you will have no way of populating your posts from the backend administration area.

Note

It’s a good idea to set show_ui to true, even for CPTs that won’t generally be added or edited through the administrator dashboard. For example, the bbPress plugin adds Topics and Replies as CPTs that are added and edited through the forum UI on the frontend. However, show_ui is set to true, providing another interface from which administrators can search, view, and manage topics and replies.

menu_position

An optional integer used to set the menu order of a post type menu item in the backend, left-side navigation.

The WordPress Codex provides a nice list of common menu position values to help you figure out where to place the menu item for your CPT:

  • 5: below Posts

  • 10: below Media

  • 15: below Links

  • 20: below Pages

  • 25: below comments

  • 60: below first separator

  • 65: below Plugins

  • 70: below Users

  • 75: below Tools

  • 80: below Settings

  • 100: below second separator

menu_icon

An optional string of a URL to a custom icon that can be used to represent a post type.

can_export

An optional Boolean that specifies whether a post type can be exported via the WordPress exporter in Tools → Export. This argument is set to true by default, allowing the administrator to export.

show_in_nav_menus

An optional Boolean that specifies whether posts from a post type can be added to a custom navigation menu in Appearance → Menus. The default value of this argument is set to the value of the public argument.

show_in_menu

An optional Boolean or string that specifies whether to show the post type in the backend admin menu and possibly where to show it. If set to true, the post type is displayed as its own item on the menu. If set to false, no menu item for the post type is shown. You can also pass in a string of the name of any other menu item. Doing this places the post type in the submenu of the passed-in menu item. The default value of this argument is set to the value of the show_ui argument.

show_in_admin_bar

An optional Boolean that specifies whether a post type is available in the WordPress administrator bar. The default value of this argument is set to the value of the show_in_menu argument.

delete_with_user

An optional Boolean that specifies whether to delete all the posts for a post type created by a given user. If set to true, posts the user created will be moved to the trash when the user is deleted. If set to false, posts will not be moved to the trash when the user is deleted. By default, posts are moved to the trash if the argument post_type_supports has author within it. If not, posts are not moved to the trash.

show_in_rest

An optional Boolean that specifies whether this CPT is exposed through the REST API. The default value of this argument is false. The REST API is covered in Chapter 10.

rest_base

An optional string to change the base slug that is used when accessing CPTs of this type through the REST API. The default value for this argument is the post type name set as the first parameter to the register_post_type() function.

rest_controller_class

An optional string to change the controller used when accessing this post type through the REST API. This argument should be set to WP_REST_Posts_Controller or the name of a PHP class that inherits the WP_REST_Posts_Controller class. The default value for this argument is WP_REST_Posts_Controller.

_builtin

You shouldn’t ever need to use this argument. Default WordPress post types use this to differentiate themselves from CPTs.

_edit_link

The URL of the edit link on the post. This is also for internal use, and you shouldn’t need to use it. If you’d like to change the page linked to when clicking to edit a post, use the get_edit_post_link filter, which passes the default edit link along with the ID of the post.

Example 5-1 illustrates registering new “Homework” and “Submissions” CPTs using register_post_type(). You can find the code for the register_post_type() function in wp-includes/post.php. Notice that in our example we are using only a few of the many available arguments.

Example 5-1. Registering a CPT
<?php
// custom function to register a "homework" post type
function schoolpress_register_post_type_homework() {
    register_post_type( 'homework',
        array(
            'labels' => array(
        		'name' => __( 'Homework' ),
				'singular_name' => __( 'Homework' )
			),
		'public' => true,
		'has_archive' => true,
		)
	);
}
// call our custom function with the init hook
add_action( 'init', 'schoolpress_register_post_type_homework' );

// custom function to register a "submissions" post type
function schoolpress_register_post_type_submission() {
    register_post_type( 'submissions',
        array(
            'labels' => array(
				'name' => __( 'Submissions' ),
				'singular_name' => __( 'Submission' )
			),
		'public' => true,
		'has_archive' => true,
		)
	);
}
// call our custom function with the init hook
add_action( 'init', 'schoolpress_register_post_type_submission' );
?>

If you dropped the preceding code in your active theme’s functions.php file or an active plugin, you should notice two new menu items on the WordPress admin called Homework and Submissions under the Comments menu item.

What Is a Taxonomy and How Should I Use It?

We briefly touched on taxonomies in Chapter 2, but what exactly is a taxonomy? Taxonomies group posts by terms. Think post categories and post tags; these are just built-in taxonomies attached to the default “post” post type. You can define as many custom taxonomies or categories as you want and span them across multiple post types. For example, we can create a custom taxonomy “Subject” that has all school-related subjects as its terms and is tied to our “Homework” CPT.

Taxonomies Versus Post Meta

One question you will often encounter when you want to attach bits of data to posts is whether to use a taxonomy or a post meta field (or both). Generally, terms that group different posts together should be coded as taxonomies, while data specific to each individual post should be coded as post meta fields.

Post meta fields

These work well for data specific to individual posts and not used to group posts together. In SchoolPress, it makes sense to code things like “required assignment length” (e.g., 500 words) as a meta field. In practice, only a few different lengths will ever be used, but we won’t ever need to “get all assignments that require 500 words.” So a post meta field is adequate for this information.

Taxonomies

These are good for data that is used to group posts together. In SchoolPress, it makes sense to code things such as an assignment’s subject (e.g., math or English) as a taxonomy. Unlike assignment length, we will want to run queries like “get all math assignments.” This is easily done through a taxonomy query. More important, queries like this run faster on taxonomy data than on meta fields.

Why are taxonomy queries generally faster? Meta fields are stored in the wp_postmeta. If we were storing an assignment’s due date as a post meta field, it would look like this:

meta_id

post_id

meta_key

meta_value

1

1

due_date

2018-09-07

2

2

due_date

2018-09-14

The meta_id, post_id, and meta_key columns are indexed, but the meta_value column is not. This means that queries like “get the due date for this assignment” will run quickly, but queries like “get all assignments due on 2018-09-07” will run slower, especially if you have a large site with lots of data piled into the wp_postmeta table. The reason the meta_value key is the lone column in wp_postmeta without an index is that adding an index here would greatly increase both the storage required for this table and also the insert times. In practice, a site will have many different meta values, whereas there will be a smaller set of post IDs and meta keys to build indexes for.

If you stored assignment due dates in a custom taxonomy, the “get all assignments due on this date” query will run much faster. Each specific due date would be a term in the wp_terms table with a corresponding entry in the wp_terms_taxonomy table. The wp_terms_relationships table that attaches terms to posts has both the object_id (posts are objects here) and term_taxonomy_id fields indexed. So “get all posts with this term_taxonomy_id" is a speedy query.

If you just want to show the due date on the assignment page, you should store it in the post meta fields. If you want to offer a report of all assignments due on a certain date, you should consider adding a taxonomy to track due dates.

On the other hand, due to the nature of due dates (you potentially have 365 new terms each year), using a taxonomy for them might be overkill. You would end up with a lot of useless terms in your database keeping track of which assignments were due two years ago.

Also, in this specific case, the speed increases might be negligible because the due date report is for a subset of assignments within a specific class group. In practice, we won’t be querying for assignments by due date across the entire wp_postmeta table. We’ll filter the query to run only on assignment posts for a specific class. While there may be millions of rows in the wp_postmeta table for a SchoolPress site at scale (hundreds of schools, thousands of teachers and classes), there will only be a few assignments for a specific class or group of classes one student is in.

Another consideration when choosing between meta fields and taxonomies is how that data will be managed by users. If a field is only going to be used in the backend code, and you don’t have query speed issues, storing it in post meta is as simple as one call to update_post_meta(). If you’d like administrators to be able to create new terms, write descriptions for them, build hierarchies, and use drop-downs or checkboxes to assign them to posts, well then we’ve just described exactly what you get for free when you register a taxonomy. When using post meta fields, you need to build your own UI into a meta box.

Finally, I did mention earlier that there are times when you want to use both a meta field and a taxonomy to track one piece of data. An example of this in the context of the SchoolPress app could be tracking a textbook and chapter for an assignment. Imagine you want a report for a student with all of their assignments organized by textbook and ordered by chapter within those books. Because you want to allow teachers to manage textbooks as terms in the administrator menu, and you’ll want to do queries like “get all assignments for this textbook,” it makes sense to store textbooks in a custom taxonomy.

On the other hand, chapters can be stored in post meta fields. Chapters are common across books and assignments, but it doesn’t make sense to query for “all chapter 1 assignments” across many different textbooks. Since we’ll be able to prefilter to get all assignments by textbook or by student, we can use a chapter meta field, or possibly a textbook_chapter meta field with data like “PrinciplesOfMath.Ch1” to order the assignments for the report.

Phew…now that we’ve figured out when we’ll want to use taxonomies, let’s find out how to create them.

register_taxonomy( $taxonomy, $object_type, $args )

The register_taxonomy() function accepts the following three parameters:

$taxonomy

A required string of the name of your taxonomy. In our example, our taxonomy name is “subject.”

$object_type

A required array or string of the CPT(s) you are attaching this taxonomy to. In our example, we are using a string and attaching the subject taxonomy to the homework post type. We could set it to more than one post type by passing in an array of post type names.

$args

This is optional array of many arguments dictates how your custom taxonomy is set up. Notice that in our example we use only a few of the many available arguments that could be passed into the register_taxonomy() function.

Following is a list of all available arguments:

label

Optional string of the display name of your taxonomy.

labels

Optional array of labels to use for describing your taxonomy throughout the user interface:

name

The plural display name of your taxonomy. This will override the label argument.

singular_name

The name for one object of this taxonomy. Defaults to “Category.”

search_items

Defaults to “Search Categories.”

popular_items

This string isn’t used on hierarchical taxonomies. Defaults to “Popular Tags.”

all_items

Defaults to “All Categories.”

parent_item

This string is only used on hierarchical taxonomies. Defaults to “Parent Category.”

parent_item_colon

The same as the parent_item argument but with a colon at the end.

edit_item

Defaults to “Edit Category.”

view_item

Defaults to “View Category.”

update_item

Defaults to “Update Category.”

add_new_item

Defaults to “Add New Category.”

new_item_name

Defaults to “New Category Name.”

separate_items_with_commas

This string is used on nonhierarchical taxonomies. Defaults to “Separate tags with commas.”

add_or_remove_items

This string is used on nonhierarchical taxonomies. Defaults to “Add or remove tags.”

choose_from_most_used

This string is used on nonhierarchical taxonomies. Defaults to “Choose from the most used tags.”

hierarchical

Optional Boolean that specifies whether a taxonomy is hierarchical or if a taxonomy term may have parent terms or subterms. This is like the default categories taxonomy, and nonhierarchical taxonomies are like the default tags taxonomy. The default value for this argument is set to false.

update_count_callback

Optional string that works like a hook. It’s called when the count of the associated post type is updated.

rewrite

Optional Boolean or array used to customize the permalink structure of a taxonomy. The default rewrite value is set to the taxonomy slug.

query_var

Optional Boolean or string that can be used to customize the query_var, ?$query_var=$term. By default, the taxonomy name is used as the query_var.

public

Optional Boolean that specifies whether a taxonomy should be used publicly in the backend or frontend of WordPress. By default, this argument is false; so if you didn’t include this argument and set it to true, you couldn’t use this taxonomy in your theme. If you set public to true, it automatically sets show_ui, publicly_queryable, and show_in_nav_menus to true unless otherwise noted.

publicly_queryable

Optional Boolean that specifies whether the taxonomy should be publicly queryable on the frontend. The default is set to true.

show_ui

Optional Boolean that specifies whether the taxonomy will have a backend admin UI, similar to the categories or tags interface. The default value of this argument is set to the value of the public argument.

show_in_nav_menus

Optional Boolean specifying if a taxonomy will be available in navigation menus. This argument’s default value is set to the value of the public argument.

show_in_rest

Optional Boolean that specifies whether this taxonomy is exposed through the REST API. The default value of this argument is false. The REST API is covered in Chapter 10.

rest_base

Optional string to change the base slug that is used when accessing CPTs with this taxonomy through the REST API. The default value for this argument is the taxonomy name set as the first parameter to the register_taxonomy() function.

rest_controller_class

Optional string to change the controller used when accessing posts with this taxonomy through the REST API. This argument should be set to WP_REST_Terms_Controller or the name of a PHP class that inherits that class. The default value for this argument is WP_REST_Terms_Controller.

show_tagcloud

Optional Boolean that specifies whether the taxonomy can be included in the Tag Cloud Widget. The default value of this argument is set to the value of the show_ui argument.

show_admin_column

Optional Boolean that specifies whether a new column will be created for your taxonomy on the post type it is attached to on the post type’s edit/list page in the backend. This is false by default.

capabilities

Optional array of capabilities for this taxonomy with a default of none. You can pass in the following arguments and/or any custom-created capabilities:

  • manage_terms

  • edit_terms

  • delete_terms

  • assign_terms

In our homework post type example, we are going to make a taxonomy called “Subject” so we can create a term for each subject like math, science, language arts, and social studies:

<?php
// custom function to register the "subject" taxonomy
function schoolpress_register_taxonomy_subject() {
   register_taxonomy(
      'subject',
      'homework',
      array(
         'label' => __( 'Subjects' ),
         'rewrite' => array( 'slug' => 'subject' ),
         'hierarchical' => true
      )
   );
}
// call our custom function with the init hook
add_action( 'init', 'schoolpress_register_taxonomy_subject' );
?>

Notice in the preceding code the subject taxonomy is set up like categories on a post because it’s hierarchical argument is set to true. You can create as many subjects as you would like and nest them.

Under Homework → Subjects in the backend, you can add your terms the same way you would add new categories to a post.

Using CPTs and Taxonomies in Your Themes and Plugins

In the following sections, we cover some things to keep in mind when using CPTs in your themes or plugins.

Good Old WP_Query and get_posts()

In some instances, creating an archive and single .php file for your custom post type may not be enough for the custom functionality you require. What if you want to loop through all the posts for a specific post type in a sidebar widget or in a shortcode on a page? With WP_Query or get_posts(), you can set the post_type parameter to query and loop through your CPT posts the same way you would with regular posts.

In Example 5-2, we’ll build a homework submission form below any content provided for the single post of the homework post type.

Example 5-2. Homework submission form
<?php
function schoolpress_the_content_homework_submission($content){

   global $post, $current_user;

   // Don't do this for any other post type than homework.
   if ( ! is_single() || $post->post_type != 'homework' )
       return $content;

   // Don't do this if the user is not logged in.
   if ( ! is_user_logged_in() )
       return $content;

	// Check if the current user has already made a submission
    // to this homework assignment.
	$submissions = get_posts( array(
	    'post_author'    => $current_user->ID,
		'posts_per_page' => '1',
		'post_type'      => 'submissions',
		'meta_key'       => '_submission_homework_id',
		'meta_value'     => $post->ID
	) );
	foreach ( $submissions as $submission ) {
		$submission_id = $submission->ID;
	}

	// Process the form submission if the user hasn't already.
	if ( !$submission_id &&
			isset( $_POST['submit-homework-submission'] ) &&
			isset( $_POST['homework-submission'] ) ) {

		$submission = $_POST['homework-submission'];
		$post_title = $post->post_title;
		$post_title .= ' - Submission by ' . $current_user->display_name;
		// Insert the current users submission as a post into our
        // submissions CPT.
		$args = array(
			'post_title'   => $post_title,
			'post_content' => $submission,
			'post_type'    => 'submissions',
			'post_status'  => 'publish',
			'post_author'  => $current_user->ID
		);
		$submission_id = wp_insert_post( $args );
		// Add post meta to tie this submission post to the homework post.
		add_post_meta( $submission_id, '_submission_homework_id',
        $post->ID );
		// Create a custom message.
		$message = __(
			'Your homework has been submitted and is
             awaiting review.',
			'schoolpress'
		);
		$message = '<div class="homework-submission-message">' . $message .
          '</div>';
		// Drop message before the filtered $content variable.
		$content = $message . $content;
	}

	// Add a link to the user's submission if a submission was already made.
	if( $submission_id ) {

		$message = sprintf( __(
			'Click %s here %s to view your submission to this homework
              assignment.',
			'schoolpress' ),
			'<a href="' . get_permalink( $submission_id ) . '">',
			'</a>' );
		$message = '<div class="homework-submission-link">' . $message .
           '</div>';
		$content .= $message;

	// Add a basic submission form after the $content variable being filtered.
	} else {

	 ob_start();
	 ?>
	 <h3><?php _e( 'Submit your Homework below!', 'schoolpress' );?></h3>
	 <form method="post">
	 <?php
	 wp_editor( '', 'homework-submission', array( 'media_buttons' => false ) );
	 ?>
	 <input type="submit" name="submit-homework-submission" value="Submit" />
	 </form>
	 <?php
	 $form = ob_get_contents();
	 ob_end_clean();
	 $content .= $form;
	}

	return $content;
}
// Add a filter on 'the_content' so we can run our custom code
// to deal with homework submissions
add_filter( 'the_content', 'schoolpress_the_content_homework_submission', 999 );
?>

You have probably noticed that we haven’t yet discussed the following functions:

ob_start()

This PHP function is used to turn output buffering on. While output buffering is active, no output is sent to the browser; instead, the output is stored in an internal buffer.

wp_editor()

This WordPress function outputs the same WYSIWYG editor that you get while adding or editing a post. You can call this function anywhere you would like to stick an editor. We thought the homework submission form would be a perfect place. We cover all the parameters of this function in Chapter 7.

ob_get_contents()

We set a variable called $form to this PHP function. This makes all content between calling the ob_start() function and this function into a variable called $form.

ob_end_clean()

This PHP function clears the output buffer and turns off output buffering.

We used these functions in the previous sequence because the wp_editor() function does not currently have an argument to return the editor as a variable and outputs it to the browser when it’s called. If we didn’t use these functions, we wouldn’t be able to put our editor after the $content variable passed into the the_content filter.

In the following code, we’ll make sure that only administrators have access to all homework submissions and that other users can access only the homework submissions they created:

<?php
function schoolpress_submissions_template_redirect(){
    global $post, $user_ID;

    // only run this function for the submissions post type
    if ( $post->post_type != 'submissions' )
    	return;

    // check if post_author is the current user_ID
    if ( $post->post_author == $user_ID )
    	$no_redirect = true;

    // check if current user is an administrator
    if ( current_user_can( 'manage_options' ) )
    	$no_redirect = true;

    // if $no_redirect is false redirect to the home page
    if ( ! $no_redirect ) {
    	wp_redirect( home_url() );
    	exit();
    }
}
// Use the template_redirect hook to call a function that decides if the
// current user can access the current homework submission.
add_action( 'template_redirect', 'schoolpress_submissions_template_redirect' );
?>

Metadata with CPTs

You can utilize the same post meta functions we went over in detail in Chapter 2 with any CPT you create. Getting, adding, updating, and deleting post metadata is consistent across all posts types.

If you registered a CPT and added custom-fields in the supports argument, by default, when adding a new post or editing a post of that post type, you will see a meta box called “Custom Fields.” You may already be familiar with this meta box; it’s a very basic form used to maintain metadata attached to a post.

Note

If you don’t see the Custom Fields section on the edit post page, you may need to enable it. If you are using the Block Editor, click the More Tools and Options button (the three dots in the upper right). At the bottom of the page, click Options and then, in the Advanced Panels section, find the checkbox for Custom Fields. If you’re using the Classic Editor, at upper right, click the Screen Options tab, then find the Custom Fields checkbox.

What if you require a slicker UI for adding metadata on the backend? Well, building a custom meta box would be the solution for you.

add_meta_box( $id, $title, $callback, $screen, $context, $priority, $callback_args )

The add_meta_box() function will add a meta box to one or more screens:

$id

A required string of a unique identifier for the meta box you are creating.

$title

A required string of the title or visible name of the meta box you are creating.

$callback

A required string of a function name that’s called to output the HTML inside the meta box you are creating.

$screen

The optional screen or screens where your meta box will appear. Accepts a screen ID, WP_Screen object, or array of screen IDs. The default is null.

$context

An optional string of the context within the page where your meta box should show (normal, advanced, side). The default is advanced.

$priority

An optional string of the priority within the context where the boxes should show (high, low).

$callback_args

An optional array of arguments that will be passed in the callback function you referenced with the $callback parameter. Your callback function will automatically receive the $post object and any other arguments you set here.

Note

While we focus here on how to add meta boxes to the edit post screen, meta boxes can be used on any admin screen. To use meta boxes on your own page, use a unique value for the screen parameter and then call do_meta_boxes() for that screen in your page’s callback function. This is good for reporting pages or other pages where you’d like users to be able to hide boxes, rearrange boxes, or add new boxes through custom hooks.

In Example 5-3, we are going to build a custom meta box for all posts of our homework post type. This meta box will contain a checkbox indicating whether a homework submission is required as well as a date selector for the homework assignment’s due date.

Example 5-3. Custom meta box
<?php
// Callback for adding a custom meta box.
function schoolpress_homework_add_meta_boxes(){

    add_meta_box(
        'homework_meta',
        'Additonal Homework Info',
        'schoolpress_homework_meta_box',
        'homework',
        'side'
    );

}
// Use the add_meta_boxes hook to call a custom function to add a new meta box.
add_action( 'add_meta_boxes', 'schoolpress_homework_add_meta_boxes' );

// This is the callback function called from add_meta_box.
function schoolpress_homework_meta_box( $post ){
    // Using 2 liens here so the url will fit in the book ;)
    $smoothness_url = 'http://ajax.googleapis.com/ajax/libs/';
    $smoothness_url.= 'jqueryui/1.12.1/themes/smoothness/jquery-ui.css';

    // Enqueue jquery date picker.
    wp_enqueue_script( 'jquery-ui-datepicker' );
    wp_enqueue_style( 'jquery-style', $smoothness_url );

    // Set metadata if already exists.
    $is_required = get_post_meta( $post->ID,
        '_schoolpress_homework_is_required', 1 );

    $due_date = get_post_meta( $post->ID,
        '_schoolpress_homework_due_date', 1 );
    // Output metadata fields.
    ?>
    <p>
    <input type="checkbox"
    name="is_required" value="1" <?php checked( $is_required, '1' ); ?>>
    This assignment is required.
    </p>
    <p>
    Due Date:
    <input type="text"
    name="due_date" id="due_date" value="<?php echo $due_date;?>">
    </p>
    <script>
    // Attach jQuery date picker to our due_date field.
    jQuery(document).ready(function() {
        jQuery('#due_date').datepicker({
            dateFormat : 'mm/dd/yy'
        });
    });
    </script>
    <?php
}

// Callback for saving custom metadata to the database.
function schoolpress_homework_save_post( $post_id ){

  // Don't save anything if WP is auto saving.
  if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE )
      return $post_id;

  // Check if correct post type and that the user has correct permissions.
  if ( 'homework' == $_POST['post_type'] ) {

    if ( ! current_user_can( 'edit_page', $post_id ) )
        return $post_id;

  } else {

    if ( ! current_user_can( 'edit_post', $post_id ) )
        return $post_id;
  }

  // Update homework metadata.
  update_post_meta( $post_id,
    '_schoolpress_homework_is_required',
    $_POST['is_required']
  );
  update_post_meta( $post_id,
    '_schoolpress_homework_due_date',
    $_POST['due_date']
  );

}
// Call a custom function to handle saving our metadata.
add_action( 'save_post', 'schoolpress_homework_save_post' );
?>

If you are a good developer, you’re probably thinking to yourself: where are the nonces? How come these $_POST values aren’t sanitized? If you aren’t thinking this, you should be, because security is very important! If you don’t know what we are talking about, see Chapter 8, where will cover these best practices (and nonces) in more detail. We deliberately left out this additional code to keep the example short and sweet, but know that when you are writing custom code, you should always use nonces and sanitize your data.

Note

When creating meta boxes and custom meta fields, we recommend utilizing the CMB2 plugin. You can easily include CMB2 in your theme or any custom plugin to give you a fast and easy way to create custom meta boxes and the meta fields inside them.

Using Meta Boxes with the Block Editor

The add_meta_box() function works with both the Block Editor and the Classic Editor. In both cases, meta boxes added with the “side” context will show up in the right sidebar of the edit post screen. With the Classic Editor, all sidebar meta boxes are always visible.2 With the Block Editor, sidebar meta boxes appear under the Document tab in the right sidebar.

With both versions of the editor, meta boxes added with the normal or advanced contexts will show up below the main editor area.

Because you can also use blocks to update post meta, some meta boxes may work better as a block instead. We cover blocks more fully in Chapter 11, but following are some questions to ask yourself when deciding whether a certain field or function should be developed as a Custom Block Type or a meta box. The answers here are not hard-and-fast rules, but should be read as a suggestion to lean toward one implementation over another.

Does this metadata need to be set for every post of this type?

Use a meta box with the “normal” context to show up below the post editor. Alternatively, you can use a block along with a block template so every new post contains the block by default. More on block templates in Chapter 11.

Will the controls for this metadata fit in the sidebar?

If not, use a meta box with the “normal” context to show up below the post editor. There will be a larger area for your forms and fields to fit into. Paid Memberships Pro’s “Require Membership” meta box, with its single set of checkboxes, fits nicely into the sidebar. WooCommerce’s pricing fields, with tabs of their own, fit better below the post body.

Does this metadata need to be placed within the post content?

Use a Custom Block Type so you can position the block within the post body.

Custom Wrapper Classes for CPTs

CPTs are just posts. So you can use a call like get_post( $post_id ) to get an object of the WP_Post class to work with. For complex CPTs, it helps to create a wrapper class so you can interact with your CPT in a more object-oriented way.

The basic idea is to create a custom-defined PHP class that includes as a property a post object generated from the ID of the CPT post. In addition to storing that post object, the wrapper class also houses methods for all of the functionality related to that CPT.

Example 5-4 shows the outline of a wrapper class for our homework CPT.

Example 5-4. Homework CPT wrapper class
<?php
/*
    Class wrapper for homework CPT
    /wp-content/plugins/schoolpress/classes/class.homework.php
*/
class Homework {
    // Constructor can take a $post_id.
    function __construct( $post_id = NULL ) {
        if ( !empty( $post_id ) )
            $this->getPost( $post_id );
    }

    // Get the associated post and prepopulate some properties.
    function getPost( $post_id ) {
        //get post
        $this->post = get_post( $post_id );

        //set some properties for easy access
        if ( !empty( $this->post ) ) {
        $this->id = $this->post->ID;
        $this->post_id = $this->post->ID;
        $this->title = $this->post->post_title;
        $this->teacher_id = $this->post->post_author;
        $this->content = $this->post->post_content;
        $this->required = $this->post->_schoolpress_homework_is_required;
        $this->due_date = $this->post->due_date;
        }

        // Return post id if found or false if not.
        if ( !empty( $this->id ) )
            return $this->id;
        else
            return false;
    }
}
?>

The constructor of this class can take a $post_id as a parameter and will pass that to the getPost() method, which attaches a $post object to the class instance and also prepopulates a few properties for easy access. Example 5-5 shows how to instantiate an object for a specific homework assignment and print out the contents.

Example 5-5. Get and print a specific homework assignment
$assignment_id = 1;
$assignment = new Homework($assignment_id);
echo '<pre>';
print_r($assignment);
echo '</pre>';
//Outputs:
/*
Homework Object
(
    [post] => WP_Post Object
        (
            [ID] => 1
            [post_author] => 1
            [post_date] => 2013-03-28 14:53:56
            [post_date_gmt] => 2013-03-28 14:53:56
            [post_content] => This is the assignment...
            [post_title] => Assignment #1
            [post_excerpt] =>
            [post_status] => publish
            [comment_status] => open
            [ping_status] => open
            [post_password] =>
            [post_name] => assignment-1
            [to_ping] =>
            [pinged] =>
            [post_modified] => 2013-03-28 14:53:56
            [post_modified_gmt] => 2013-03-28 14:53:56
            [post_content_filtered] =>
            [post_parent] => 0
            [guid] => http://schoolpress.me/?p=1
            [menu_order] => 0
            [post_type] => homework
            [post_mime_type] =>
            [comment_count] => 3
            [filter] => raw
            [format_content] =>
        )

    [id] => 1
    [post_id] => 1
    [title] => Assignment 1
    [teacher_id] => 1
    [content] => This is the assignment...
    [required] => 1
    [due_date] => 2013-11-05
)
*/

Extending WP_Post Versus Wrapping It

Another option here would be to extend the WP_Post class, but currently this is not possible, as the WP_Post class is defined as final, meaning it is a class that can’t be extended. The WordPress core team has said they’re doing this to keep people from building plugins that rely on extending the WP_Post object since WP_Post is due for overhaul in future versions of WordPress. We think they’re being big fuddy duddies.3

In Chapter 6, we extend the WP_User class (which isn’t defined as final). But the best we can do with WP_Post is to create a wrapper class for it.

Keep Your CPTs and Taxonomies Together

Put all of your code to register the CPT and taxonomies in one place. Instead of having one block of code to register a CPT and define the taxonomies and a separate class wrapper to handle working with the CPT, you can place your CPT and taxonomy definitions into the class wrapper itself:

/*
	Class wrapper for homework CPT with init function
	/wp-content/plugins/schoolpress/classes/class.homework.php
*/
class Homework
{
	// Constructor can take a $post_id.
	function __construct($post_id = NULL)
	{
		if(!empty($post_id))
			$this->getPost($post_id);
	}

	// Get the associated post and prepopulate some properties.
	function getPost($post_id)
	{
		/* snipped */
	}

	// Register CPT and taxonomies on init.
	function init()
	{
		// Register the homework CPT.
		register_post_type(
			'homework',
			array(
				'labels' => array(
					'name' => __( 'Homework' ),
					'singular_name' => __( 'Homework' )
				),
				'public' => true,
				'has_archive' => true,
			)
		);

		// Register the subject taxonomy.
		register_taxonomy(
			 'subject',
			 'homework',
			 array(
			 	'label' => __( 'Subjects' ),
			 	'rewrite' => array( 'slug' => 'subject' ),
			 	'hierarchical' => true
			 )
		);
	}
}

// Run the Homework init on init.
add_action('init', array('Homework', 'init'));

This code has been snipped (the full version can be found on this book’s GitHub site), but shows how you would add an init() method to your class that is hooked into the init action. The init() method then runs all the code required to define the CPT. You could also define other hooks and filters here, with the callbacks linked to other methods in the Homework class.

You can organize things in other ways, but we find that having all of your CPT-related code in one place helps a lot.

Keep It in the Wrapper Class

Build all of your CPT-related functionality as methods on the wrapper class. When we registered our homework CPT, a page was added to the dashboard allowing us to “Edit Homework.” Teachers can create homework like any other post, with a title and body content. Teachers can publish the homework when it’s ready to be pushed out to students. All of this post-related functionality is available for free when you create a CPT.

On the other hand, much of the functionality around many CPTs, including our homework CPT, needs to be coded up. With a wrapper class in place, this functionality can be added as methods of our Homework class.

For example, one thing we want to do with our homework posts is to gather all the submissions for a particular assignment. Once we do this, we can render them in a list or process them in some way. Example 5-6 shows a couple of methods we can add to our Homework class to gather related submissions and to calculate a flat-scale grading curve.

Example 5-6. Adding methods to the Homework class
<?php
class Homework
{
	/* Snipped constructor and other methods from earlier examples */

	/*
		Get related submissions.
		Set $force to true to force the method to get children again.
	*/
	function getSubmissions($force = false)
	{
		// Need a post ID to do this.
		if(empty($this->id))
			return array();

		// Did we get them already?
		if(!empty($this->submissions) && !$force)
			return $this->submissions;

		// Okay get submissions.
		$this->submissions = get_children(array(
			'post_parent' => $this->id,
			'post_type' => 'submissions',
			'post_status' => 'published'
		));

		// Make sure submissions is an array at least.
		if(empty($this->submissions))
			$this->submissions = array();

		return $this->submissions;
	}

	/*
		Calculate a grade curve
	*/
	function doFlatCurve($maxscore = 100)
	{
		$this->getSubmissions();

		// Figure out the highest score.
		$highscore = 0;
		foreach($this->submissions as $submission)
		{
			$highscore = max($submission->score, $highscore);
		}

		// Figure out the curve.
		$curve = $maxscore - $highscore;

		// Fix lower scores.
		foreach($this->submissions as $submission)
		{
			update_post_meta(
				$submission->ID,
				"score",
				min( $maxscore, $submission->score + $curve )
			);
		}
	}
}
?>

Wrapper Classes Read Better

In addition to organizing your code to make things easier to find, working with wrapper classes also makes your code easier to read and understand. With fully wrapped Homework and Submission CPTs and special user classes (covered in Chapter 6), code like the following is possible:

<?php
// Use a method of the Student class to check if the current user is a student
if ( Student::is_student() ) {
    // Student defaults to current user.
    $student = new Student();

    // Let's figure out when their next assignment is due.
    $assignment = $student->getNextAssignment();

    // Display info and links.
    if ( !empty( $assignment ) ) {
    ?>
    <p>Your next assignment
    <a href="<?php echo get_permalink( $assignment->id );?>">
    <?php echo $assignment->title;?></a>
    for the
    <a href="<?php echo get_permalink( $assignment->class_id );?>">
    <?php echo $assignment->class->title;?></a>
    class is due on <?php echo $assignment->getDueDate();?>.</p>
    <?php
    }
}
?>

The code would be much more complicated if all the get_post() calls and loops through arrays of child posts were out in the open. Using an object-oriented approach makes your code more accessible to other developers working it.

1 The new privacy controls were motivated by the EU GPDR, passed in 2018. The regulation applies not just to all EU residents, but also all sites with EU visitors. So pretty much all sites. The idea of allowing users to export or delete noncritical personal data is a good one no matter where you live.

2 Although, clicking the heading of a meta box, no matter where it is displayed, hides or shows the full meta box content.

3 But seriously, the core team is really smart and makes a good point. If someone extended the WP_Post class and created new properties and methods for their new class, it would cause trouble if WordPress core later chose to use properties and methods with the same names in the base WP_Post class.