WordPress CPTs & Taxonomies

A guide to custom content types.

Custom Post Types (CPTs)

What are CPTs?

Custom Post Types are user-defined content types, separate from WordPress's default types like post, page, attachment, revision, and nav_menu_item.

  • Examples: Products, Movies, Reviews, Recipes.

Why use CPTs?

They keep your content modular and clean by separating different types of data (e.g., "Movies" from "Posts"). This provides dedicated admin menus, archival capabilities, custom templates, and a more intuitive content management experience.

Registering a CPT

You register a CPT using the register_post_type() function inside a function hooked to init. The CPT key must not exceed 20 characters and should always be prefixed to prevent conflicts (e.g., wporg_movie, not just movie).

Critical Arguments

'labels'
An array of UI labels used in the admin area (e.g., plural name, singular name, add new, edit item, etc.).
'public', 'show_ui', 'show_in_menu'
A set of boolean arguments that control the visibility of the CPT in the admin UI and on the front-end.
'rewrite'
An array for configuring the URL structure (slug). Options include 'slug', 'with_front', etc.
'supports'
An array defining which core features are enabled, such as 'title', 'editor', 'thumbnail', 'custom-fields', and 'revisions'.
'taxonomies'
An array to attach existing taxonomies (core or custom) to this post type upon registration.
'has_archive'
Enables a post type archive page. Can be a boolean or a string to specify a custom archive slug.
'show_in_rest'
Crucial for modern WordPress. Set to true to make the CPT available to the Block Editor (Gutenberg) and the REST API.
'capability_type' & 'capabilities'
Used for defining advanced, granular user permissions for the CPT.

Naming, Slugs, and Rewrite Rules

Avoid using reserved keys (like post, page, attachment) for your CPT. Also, ensure your URL slugs are unique to prevent conflicts. Whenever rewrite rules for a CPT change, you must flush them. This is typically done on plugin activation to avoid performance issues.

Custom Taxonomies

What are Taxonomies?

Taxonomies are systems for classifying and grouping content. WordPress comes with two defaults: Categories (hierarchical) and Tags (flat/non-hierarchical). Custom taxonomies allow you to create your own classification systems.

  • Examples: "Genres" for a "Movies" CPT, "Cuisines" for a "Recipes" CPT.

Why use custom taxonomies?

They provide structured, context-specific classification beyond generic tags or categories. This adds separate admin screens and meta boxes, making content entry more intuitive for users.

Registering a Taxonomy

Use the register_taxonomy() function on the init hook. The name must be 32 characters or less and contain only lowercase letters, numbers, underscores, and dashes. You can attach it to one or more post types during registration or afterward with register_taxonomy_for_object_type().

Critical Arguments

'labels'
An array of UI labels for the admin area (e.g., name, singular name, search items, etc.).
'hierarchical'
A boolean. If true, the taxonomy behaves like categories (with parent/child relationships). If false, it behaves like tags.
'show_ui', 'show_admin_column'
Controls visibility in the admin UI and adds a column to the associated post type's list table for easy filtering.
'rewrite'
An array to control the URL slug for the taxonomy archive.
'show_in_rest'
Set to true to make the taxonomy available to the Block Editor and REST API.

Custom Queries & Templates

Template Files

WordPress automatically looks for specific template files to display your custom content, following a hierarchy:

  • Single CPT entry: single-{post_type}.php (e.g., single-wporg_movie.php)
  • CPT Archive: archive-{post_type}.php (e.g., archive-wporg_movie.php)
  • Custom Taxonomy Archive: taxonomy-{taxonomy}.php (e.g., taxonomy-genre.php)

If these specific files are not found, WordPress falls back to more generic templates like single.php and archive.php.

Custom Queries (WP_Query)

To fetch and display your CPT entries in a custom loop, use the WP_Query class. Specify your CPT key in the post_type argument.

$loop = new WP_Query([
    'post_type'      => 'movie',
    'posts_per_page' => 10,
    'tax_query' => [ /* Optional: for filtering by taxonomy */ ]
]);

while ($loop->have_posts()) {
    $loop->the_post();
    the_title();
    the_content();
}

Altering The Main Query

To include your CPTs in the main blog loop or on archive pages, you should modify the main query using the pre_get_posts hook. This is the correct way to alter the query before it runs, avoiding performance issues and pagination problems.

function modify_main_query($query) {
  if (is_home() && $query->is_main_query()) {
     $query->set('post_type', ['post', 'movie']);
  }
}
add_action('pre_get_posts', 'modify_main_query');

Performance, Pitfalls & Advanced Tips

Common Pitfalls

  • Flushing rewrite rules: NEVER flush rewrite rules on every request (e.g., in the init hook). Only do it on plugin activation/deactivation.
  • Too many CPTs: Registering too many custom post types can impact performance. The registration process runs on every site request, even if the CPT isn't used on a page. Be mindful of overuse and consider custom database tables for complex data storage needs.
  • Generic keys/slugs: Avoid overly generic keys like 'item' or 'event' to prevent conflicts with other plugins or themes. Always prefix.
  • Slow Queries: Complex queries with multiple taxonomy conditions (tax_query) can be slow. Ensure your database is properly indexed if you encounter performance issues.

Must-Know Hacks and Tips

  • Always set 'show_in_rest' => true for Block Editor (Gutenberg) and REST API compatibility.
  • Use 'exclude_from_search' => false if you want your CPTs to appear in sitewide search results (the default is based on the 'public' argument).
  • For CPTs that should behave like Pages, set 'hierarchical' => true to enable parent/child relationships.
  • Use 'menu_icon' to specify a Dashicon for your CPT's admin menu item, making it easily identifiable.
  • Set 'show_admin_column' => true when registering a taxonomy to automatically create a sortable column on the CPT's list screen.

End-to-End Example: "Movie" CPT with "Genre" Taxonomy

This complete example registers a "Movie" CPT and a corresponding "Genre" taxonomy, tying them together. This code should be placed in a plugin or your theme's functions.php file.

function wporg_register_custom_post_type_and_taxonomy() {
    // Register Custom Post Type: Movie
    $labels = [
        'name'               => _x('Movies', 'post type general name', 'textdomain'),
        'singular_name'      => _x('Movie', 'post type singular name', 'textdomain'),
        'menu_name'          => _x('Movies', 'admin menu', 'textdomain'),
        'add_new_item'       => __('Add New Movie', 'textdomain'),
        'edit_item'          => __('Edit Movie', 'textdomain'),
        'all_items'          => __('All Movies', 'textdomain'),
    ];
    $args = [
        'labels'             => $labels,
        'public'             => true,
        'publicly_queryable' => true,
        'show_ui'            => true,
        'show_in_menu'       => true,
        'query_var'          => true,
        'rewrite'            => ['slug' => 'movies'],
        'capability_type'    => 'post',
        'has_archive'        => true,
        'hierarchical'       => false,
        'menu_position'      => 5,
        'menu_icon'          => 'dashicons-video-alt2',
        'supports'           => ['title', 'editor', 'author', 'thumbnail', 'excerpt'],
        'taxonomies'         => ['genre'],
        'show_in_rest'       => true
    ];
    register_post_type('wporg_movie', $args);

    // Register Custom Taxonomy: Genre
    $tax_labels = [
        'name'              => _x('Genres', 'taxonomy general name', 'textdomain'),
        'singular_name'     => _x('Genre', 'taxonomy singular name', 'textdomain'),
        'search_items'      => __('Search Genres', 'textdomain'),
        'all_items'         => __('All Genres', 'textdomain'),
        'edit_item'         => __('Edit Genre', 'textdomain'),
        'update_item'       => __('Update Genre', 'textdomain'),
        'add_new_item'      => __('Add New Genre', 'textdomain'),
        'menu_name'         => __('Genre', 'textdomain'),
    ];
    $tax_args = [
        'hierarchical'      => true,
        'labels'            => $tax_labels,
        'show_ui'           => true,
        'show_admin_column' => true,
        'query_var'         => true,
        'rewrite'           => ['slug' => 'genre'],
        'show_in_rest'      => true
    ];
    register_taxonomy('genre', ['wporg_movie'], $tax_args);
}
add_action('init', 'wporg_register_custom_post_type_and_taxonomy');