Creating Custom Post Types in WordPress

This guide covers how to create Custom Post Types (CPT’s) in WordPress. CPT’s are important to WordPress developers because they enable the creation of more complex sites + web-apps than is possible with a default WordPress install.

Custom Post Types are frequently defined with additional data fields called meta fields that can be defined and made editable to admins via meta boxes.

Example applications:

  • Jokes — each post contains a joke, which are listed and displayed differently than regular blog posts
  • Job Opportunities — include Salary Range and Location meta
  • Car Listings — registered users post for-sale listings and specify Make, Model, and Year meta via dynamic dropdown menus
  • Beer Reviews — featuring a range of meta fields that include Brewery, Style, and Tasting Score

Custom Post Types can be created (registered) or modified by calling the register_post_type() function within the init action.

Custom Taxonomies and the connection to Custom Post Types

Custom Post Types are closely related to the concept of Custom Taxonomies. Taxonomies are a way to group WordPress objects such as Posts by a certain classification criteria. Developers can define Custom Taxonomies to add to WordPress’ default taxonomies: Categories, Tags, Link Categories, and Post Formats.

Although this guide focuses on CPT’s, its important to note that projects are often implemented using a thoughtful combination of Custom Post Types and Custom Taxonomies.

A classic example of a complementary post type + taxonomy is: Book as a Custom Post Type and Publisher as a Custom Taxonomy.

If your Custom Post Type needs to be related to any Custom Taxonomies, they must be identified via the optional taxonomies argument of the register_post_type() function. This argument only informs WordPress of the relation and does not register any taxonomies as a side-effect. Custom Taxonomies must be registered on their own via WordPress’ register_taxonomy() function.

Registering new Custom Post Types

Registering in a Plugin vs. Theme

Custom Post Types can be registered by plugins or by themes via their functions.php file. It’s generally recommended to go the plugin route to keep a project de-coupled from any particular theme.

In the many cases where CPT’s do not depend on activation or deactivation hooks, they can be defined by a Must-Use Plugin (mu-plugin). This special type of plugin is useful to safeguard against admins (e.g. client stakeholders with admin access) accidentally de-activating any Custom Post Types that are important to their website/app.

If a plugin or theme that registers a CPT becomes deactivated, WordPress’ default behaviour is to preserve the post data in its database, though it will become inaccessible and could break any themes or plugins that assume the CPT exists. The CPT will be restored once whatever plugin or theme that registered it is re-activated.

Basic Definition

Custom Post Types may be registered by calling WordPress’ register_post_type() function during the init action with the following arguments: a required one-word post type key, and an optional array of key => value pairs that specify all optional arguments.

The following example implements a function create_my_new_post_type() that calls register_post_type() to register a CPT called candy. The last line hooks the function to the init action using WordPress’ add_action() function. It could be included as part of a plugin or in a theme’s functions.php.

Some of the most common optional args are specified: user-facing labels for singular and plural, if the CPT is to be public (appear in search, nav, etc) or not, and whether it should have an archive (list of posts) or not.

function create_my_new_post_type() {
    register_post_type( 'candy',
            'labels' => [
                'name' => __( 'Candies' ),
                'singular_name' => __( 'Candy' )
        'public' => true,
        'has_archive' => true,
add_action( 'init', 'create_my_new_post_type' );

Tip: Namespacing

It is a good practice to namespace any CPT keys by prefixing their names with a few characters relevant to you or your project followed by an underscore, such as xx_candy. This helps avoid naming conflicts with other plugins or themes, and is particularly important if you are planning to distribute your project.

Tip: Use singular form for post type keys

The WordPress codex and Handbooks always use a singular form for post type keys by convention, and WordPress’ default types such as ‘post’ and ‘page’ are singular as well.

Detailed Definition

There are a ton of optional arguments that can be specified when registering a Custom Post Type. The WordPress Developer Documentation is the best source to review all of them: register_post_type().

Some of the more notable options include:

  • labels — array of key => value pairs that correspond to different labels. There are a ton of possible labels but the most commonly specified are ‘name’ (plural) and ‘singular_name’
  • public — boolean indicating if the post type is to be public (shown in search, etc) or not (default: false)
  • has_archive — boolean indicating if an archive (list of posts) view should exist for this post type or not (default: true)
  • supports — array of WordPress core feature(s) to be supported by the post type. Options include ‘title’, ‘editor’, ‘comments’, ‘revisions’, ‘trackbacks’, ‘author’, ‘excerpt’, ‘page-attributes’, ‘thumbnail’, ‘custom-fields’, and ‘post-formats’. The ‘revisions’ option indicates whether the post type will store revisions, and ‘comments’ indicates whether the comments count will show on the edit screen. The default value is an array containing ‘title’ and ‘editor’.
  • register_meta_box_cb — string name of a callback function that will handle creating meta boxes for the CPT so admins have an interface to input meta data
  • taxonomies — an array of string taxonomy identifiers to register with the post type
  • hierarchical — a boolean value that specifies if the CPT behaves more like pages (which can have parent/child relationships) or like posts (which don’t)

The numerous other options enable you to manage rewrite rules (e.g. specify different URL slugs), configure options related to the REST API, and set capabilities as part of managing user permissions.

Adding Meta Fields to a Custom Post Type

Enabling custom-fields

A straightforward way to enable admins to define meta fields as key->value pairs when editing a post is to include the value ‘custom-fields’ in the ‘supports’ array, as part of the args passed to register_post_type().

Adding Meta Boxes to a Custom Post Type

The above ‘custom-fields’ approach works for basic use-cases, however most projects require advanced inputs like dropdown menus, date pickers, repeating fields, etc. and a certain level of data validation.

The solution is defining meta boxes that specify inputs for each of a CPT’s meta fields and handle the validation and save process. Meta boxes must be implemented in a function whose name is passed to register_post_type() via its args as a value of the ‘register_meta_box_cb’ option.

Creating meta boxes can be tricky for the uninitiated… Stay tuned for an upcoming post dedicated solely to them!

In the meantime, I would suggest exploring solutions that simplify the process of creating meta boxes. Two excellent options are the open-source CMB2 (Custom Meta-Box 2) and Advanced Custom Fields (ACF), which offers both free and commercial options. I think the commercial ACF PRO version is well worth the $100 AUD fee to license it for unlimited sites including a lifetime of updates and upgrades.

Displaying a Custom Post Type

Posts belonging to a CPT can be displayed using single and archive templates, and can be queried using the WP_Query object.

Single template: single post view

Single templates present a single post and its content. WordPress looks for the template file single-post_type_name.php for a CPT-specific template and if it doesn’t find it, it defaults to the standard single.php template.

Archive template: list of posts view

Archive templates present lists of posts. A Custom Post Type will have an Archive if it was registered with the optional has_archive argument set to a value of true (default: false).

To create an archive template for your CPT, create a template file that follows the convention: archive-post_type_name.php. If WordPress doesn’t find this file, it defaults to the standard archive.php template.

Using the WP_Query object

WP_Query can be used in widget definitions, in templates, etc. to present posts belonging to a CPT. The following example queries for published posts of the type ‘candy’ and then loops over the results, presenting each one’s title and content as items in a list.


$args = [
  'post_type'   => 'candy',
  'post_status' => 'publish',

$candies = new WP_Query( $args );
if( $candies->have_posts() ) :
      while( $candies->have_posts() ) :
          <li><?php printf( '%1$s - %2$s', get_the_title(), get_the_content() );  ?></li>
else :
  esc_html_e( 'No candies... Go get some candy!', 'text-domain' );

The wp_reset_postdata() call is important to reset WordPress back to the original loop, so other functions that depend on it will work properly. Reference:

How to write custom wp-cli commands for next-level WordPress automation

wp-cli is a powerful tool for WordPress admins and developers to control their sites from the command-line and via scripts.

This is an introductory guide to writing custom wp-cli commands using a plugin, to enable you to expand its features and tailor its capabilities to your projects.

Custom commands are invoked like any other:

wp custom_command example_custom_subcommand

This guide assumes that you are familiar with writing and deploying basic WordPress plugins. If you need a reference, please see

Why wp-cli custom functions are useful

Custom commands can help automate the management of more complex sites, enhance one’s development workflow, and enable greater control over other themes and plugins.

Practical real-world example

On a past project, I implemented wp-cli commands that enabled me to quickly load 100’s of pages of multilingual content in English, French, and Chinese, plus the translation associations between them all, to local and remote WP installs. The internationalization plugin in play, the popular and notorious pain-in-the-ass WPML, had no wp-cli support (and still doesn’t!). Without custom commands, WPML would’ve required
manually clicking piles of checkboxes in the WordPress admin interface every time copy/translation decks or certain features were revised.

Bare-bones plugin implementation

The following assumes that the wp-cli command is present (and executable as wp), and that the example plugin has been installed and activated on the target WordPress.

To learn how to install wp, refer to the docs:

The following example code creates a basic plugin class called ExamplePluginWPCLI that is only loaded when the WP_CLI constant is defined:

if ( defined( 'WP_CLI' ) && WP_CLI ) {

    class ExamplePluginWPCLI {

        public function __construct() {

                // example constructor called when plugin loads


        public function exposed_function() {

                // give output
                WP_CLI::success( 'hello from exposed_function() !' );


        public function exposed_function_with_args( $args, $assoc_args ) {

                // process arguments 

                // do cool stuff

                // give output
                WP_CLI::success( 'hello from exposed_function_with_args() !' );



    WP_CLI::add_command( 'firx', 'ExamplePluginWPCLI' );


Adding a custom command to wp-cli

Consider the line WP_CLI::add_command( 'firx', 'ExamplePluginWPCLI' ); from the above example.

The command’s name, firx, is given as the first argument. You can choose any name for custom commands that aren’t already reserved by an existing command, provided that it doesn’t contain any special characters. It is wise to pick a unique name that minimizes the risk of conflicts with any other plugin or theme that might also add commands to wp-cli.

Defining new wp-cli commands in a class like ExamplePluginWPCLI confers a special advantage over defining them using only standalone php functions or closures: all public methods in classes passed to WP_CLI::add_command() are automatically registered with wp-cli as sub-commands.

Executing a custom command

To execute a given wp command on a target WordPress, first change your working directory to that of your WordPress installation before running the command (cd /path/to/wordpress). To run a wp command from anywhere (such as from a cron job), add the --path argument and specify the path to the target WordPress (e.g. wp --path=/var/www/ ...).

The class’ public function exposed_function() can be called via wp-cli as follows:

wp firx exposed_function

The class’ public function exposed_function_with_args() can be called via wp-cli as follows. This particular function accepts command line arguments that will get passed into it via its $args and $assoc_args variables as appropriate:

wp firx exposed_function_with_args --make-tacos=beef --supersize

The constructor __construct() is optional and is included as an example. This function is called when an instance of the plugin class is loaded and it can be used to define class variables and perform any necessary setup tasks.

Output: success, failure, warnings, and more

Sending information back to the command line

wp-cli has a number of functions for outputting information back to the command line. The most commonly used are:

// works similar to a 'return' and exits after displaying the message to STDOUT with 'SUCCESS' prefix 
WP_CLI::success( 'Message' )

// works similar to a 'return' and exits after displaying the message to STDERR with 'ERROR' prefix
WP_CLI::error( 'Message' )

// display a line of text to STDERR with no prefix 
WP_CLI::log( 'Message' ) 

// display a line of text when the --debug flag is used with your command
WP_CLI::debug( 'Message' ) 

The success() and error() functions generally serve as a return equivalent for wp-cli functions. If either of these functions are called with a single argument containing a message, the script will exit after the message is displayed.

Formatting output as tables, json, etc

wp-cli has a useful helper function called format_items() that makes it a lot easier and cleaner to output detailed information.

Available options to output json, csv, count, and yaml are brilliant for enabling command output to be easily used by scripts and/or digested by web services.

The function accepts 3 arguments:

WP-CLI::format_items( $format, $items, $fields )
  • $format – string that accepts any of: ‘table’, ‘json’, ‘csv’, ‘yaml’, ‘ids’, ‘count’
  • $items – Array of items to output (must be consistently structured)
  • $fields – Array or string containing a csv list to designate as the field names (or table headers) of the items

For example, the following code:

$fields = array ( 'name', 'rank' ); 
$items = array (
    array (
        'name' => 'bob',
        'rank' => 'underling',
    array (
        'name' => 'sally',
        'rank' => 'boss',
WP_CLI\Utils\format_items( 'table', $items, $fields );

Would output something similar to:

# +-------+-----------+
# | name  | rank      |
# +-------+-----------+
# | bob   | underling |
# +-------+-----------+
# | sally | boss      | 
# +-------+-----------+

Changing the format to ‘json’ or ‘yaml’ is as simple as swapping out the ‘table’ argument.

Input: handling arguments

Positional and associative arguments

wp-cli supports both positional arguments and associative arguments.

Positional arguments are interpreted based on the order they are specified:

wp command arg1 42

Associative arguments may be specified in any order, and they can accept values:

wp command --make-tacos=beef --supersize --fave-dog-name='Trixie the Mutt'

Both positional and associative arguments can be supported by the same command.

A function implemented with two parameters for $args and $assoc_args, such the one from the first big example in this guide exposed_function_with_args( $args, $assoc_args ), will be provided all positional arguments via the $args variable and all associative arguments via the $assoc_args variable.

Retrieving values passed to a command

Suppose we wanted to process all of the arguments for the command:

wp firx exposed_function_with_args arg1 42 --make-tacos=veggie --supersize --fave-dog-name='Trixie the Mutt'

The following example expands on the initial example’s implementation of the exposed_function_with_args() function to demonstrate how to access the values of each argument:

public function exposed_function_with_args( $args, $assoc_args ) {

    // process positional arguments - option 1
    $first_value = $args[0];  // value: "arg1"
    $second_value = $args[1]; // value: 42

    // OR - process positional arguments - option 2
    list( $first_value, $second_value ) = $args;

    // process associative arguments - option 1 
    $tacos = $assoc_args['make-tacos']; // value: "veggie"
    $supersize = $assoc_args['supersize'];  // value: true
    $dog_name = $assoc_args['fave-dog-name'];    // value: "Trixie the Mutt"

    // OR - process associative arguments - option 2 - preferred !! 
    $tacos = WP_CLI\Utils\get_flag_value($assoc_args, 'make-tacos', 'chicken' );
    $supersize = WP_CLI\Utils\get_flag_value($assoc_args, 'supersize', false );
    $dog_name = WP_CLI\Utils\get_flag_value($assoc_args, 'fave-dog-name' );

    // do cool stuff

    // provide output
    WP_CLI::success( 'successfully called exposed_function_with_args() !' );


Using get_flag_value() to help with associative arguments

wp-cli comes with a handy helper function for handling associative arguments that serves as the preferred way to access them:

WP_CLI\Utils\get_flag_value($assoc_args, $flag, $default = null)

This function is passed the $assoc_args variable, the $flag (argument name) you wish to access, and optionally a default value to fall-back on if you want it to be something other than null.

What makes get_flag_value() the preferred method for accessing values is that it takes care of implementing a few tricks, including supporting the no prefix on arguments to negate them. Consider that a professional implementation of the above --supersize option should also check for and handle the case if a --no-supersize version is passed. Using the get_flag_value() function to access the values of the argument (e.g. using “supersize” as the identifier for the middle $flag argument) would handle this case and you’d be assured that your function would automatically receive the correct ‘true’ or ‘false’ value to work with.

Working with arguments

This is an introductory guide and the examples are for illustrative purposes only. For a robust implementation, keep in mind that you will likely need to add additional logic to any function that accepts arguments to check if required items are specified or not, if expected/supported values were passed in or not, etc.

These cases can be handled by your php code and can leverage wp-cli’s output functions like warning() or error() to provide user feedback. Another option that can cover many validation cases is to leverage wp-cli’s PHPDoc features (more on that below).

Registering arguments with wp-cli

An easy way to register arguments/options, whether they’re mandatory or not, and specify any default values is to use PHPDoc comment blocks. These play an active role in wp-cli which intelligently interprets them. Refer to the section on PHPDoc near the end of this guide.

wp-cli custom functions will still work in the absence of PHPDoc comments but they won’t be as user-friendly and any arguments won’t be tightly validated.

Errors: error handling with wp-cli

Use the WP_CLI::error() function to throw an error within a class or function that implements wp-cli commands:

// throw an error that will exit the script (default behaviour)
WP_CLI::error( 'Something is afoot!' );

// throw an error that won't exit the script (consider using a warning instead)
WP_CLI::error( 'The special file is missing.', false );

Error output is written to STDERR (exit(1)), which is important to consider when writing scripts that use wp-cli and respond to error conditions.

Use WP_CLI::warning() to write a warning message to STDERR that won’t halt execution:

WP_CLI::warning( $message )

Use WP_CLI::halt() to halt execution with a specific integer return code (this one is mostly for those writing scripts):

WP_CLI::halt ( $return_code )

PHPDoc comments and controls

PHPDoc style comment blocks are more than comments when it comes to wp-cli: it interprets them to provide help text to cli users, document the command’s available options/arguments and their default values, and serve as the basis for a level of validation that that gets performed before a command’s underlying function gets called and any argument data is passed to it. Complete and descriptive comments will result in the enforcement of mandatory vs. optional parameters by wp-cli.

Implementing correct PHPDoc comments simplifies and expedites the implementation of custom commands because the developer defers certain validation checks to wp-cli (e.g. to ensure a required argument was specified) rather than implementing everything on their own.

PHPDoc comments are used as follows:

  • The first comment line corresponds to the “shortDesc” shown on the cli
  • The “meat” of a comment body corresponds to the “longDesc” may be shown when cli users mess up a command, and is shown when they specify the --help flag with a given command
  • Options (aka arguments aka parameters in this context) that are defined specify if each parameter is mandatory vs. optional, and if a single or multiple arguments are accepted

The “shortDesc” and “longDesc” may be displayed to cli users as they interact with the command. To show a full description, cli users can execute:

wp [command_name] --help

Basic PHPDoc comment structure

PHPDoc comments are placed immediately preceding both Class and method (function) declarations, and have a special syntax that starts with /**, has a 2-space-indented * prefixing each subsequent line, and ends with an indented */ on its own line:

 * Implements example command that does xyz
ClassOrMethodName {
    // ...

PHPDoc comments with parameters

The wp-cli’s Command Cookbook provides the following example of a PHPDoc comment:

     * Prints a greeting.
     * ## OPTIONS
     * <name>
     * : The name of the person to greet.
     * [--type=<type>]
     * : Whether or not to greet the person with success or error.
     * ---
     * default: success
     * options:
     *   - success
     *   - error
     * ---
     * ## EXAMPLES
     *     wp example hello Newman
     * @when after_wp_load
    function say-hello( $args, $assoc_args ) {
        // ... 

The #OPTIONS and #EXAMPLES headers within the “meat” of the comment (corresponding to the “longDesc”) are optional but are generally recommended by the wp-cli team.

The arguments/parameters are specified under the #OPTIONS headers:

  • <name> specifies a required positional argument
    • writing it as <name>... means the command accepts 1 or more positional arguments
  • [--type=<type>] specifies an optional associative argument that accepts a value
    • [some-option] without the = sign specifies an optional associative argument that serves as a boolean true/false flag
  • the example’s default: and options: provided for the [--type=<type>] argument can be specified under any argument to communicate that argument’s default value and the options available to cli users

In case word wrapping is a concern for you when help text is presented:

  • Hard-wrap (add a newline) option descriptions at 75 chars after the colon and a space
  • Hard-wrap everything else at 90 chars

More information about PHPDoc syntax in the wp-cli context is available in the docs:

Help with running wp-cli commands

There are a couple helpful reminders to keep in mind when using wp-cli that could prove useful to someone following this guide.

Specifying a correct wordpress path

wp-cli must always be run against a WordPress installation.

  • You can cd to the path where WordPress has been installed, so that the shell prompt’s working directory is at the base where all the WordPress files are (e.g. on many systems, this would be something similar to: cd /var/www/
  • Alternatively you can provide the universal argument --path when specifying any wp-cli command (e.g. --path=/var/www/ to run a command from any folder

Running wp-cli as a non-root user

It is dangerous to run wp-cli as root and it will happily inform you of this fact should you try. That’s because php scripts related to your WordPress and wp-cli would be executed with full root permissions. They could be malicious (especially risky if your site allows uploads!) or poorly implemented with bugs, and that fact unreasonably puts your whole system at risk.

A popular option is to use sudo with its -u option to run a command as another user. Assuming that you have sudo installed and correctly configured, and assuming that the user ‘www-data’ is the “web server user” on your system and that it has read/write permissions to your WordPress installation folder (these are common defaults found on Ubuntu and Debian systems), you can prefix your commands with: sudo -u www-data --.

The following executes one of wp-cli’s built-in commands, wp post list, as the www-data user:

sudo -u www-data -- wp post list

Further reading

Check out the wp-cli docs and the commands cookbook at: