WordPress Custom Post Type – Mastering Advanced WordPress Development Series

WordPress Hooks and Filters – Mastering Advanced WordPress Development Series
WordPress Custom Post Type – Mastering Advanced WordPress Development Series

Learn how to create custom post types in WordPress to add structured content beyond standard posts and pages. Follow this step-by-step guide to define, customize, and use custom post types effectively.

Every WordPress site eventually outgrows posts and pages. You start with a blog, then a client asks for a properties listing, a staff directory, or a product catalog, and cramming that into regular posts turns into a mess of categories and conventions nobody remembers a month later.

That’s what custom post types are for. This entry in the Mastering Advanced WordPress Development series walks through registering one by hand, from the code to the caveats that trip people up in production.

We’ll cover what custom post types are, when they earn their keep, and how to register one step by step.

Part 1: What are WordPress custom post types?

Before the code, the plain version.

Custom post types let you register your own content types alongside the built-in posts and pages. WordPress runs the same editing, querying, and templating machinery for them, so a “Book” or “Event” behaves like a first-class citizen instead of a post wearing a costume.

The payoff is structure. A portfolio, a product database, a knowledge base: each gets its own admin menu, its own archive, and its own place in your templates, kept separate from your blog. When the content is genuinely different from a post, a custom post type keeps it that way.

Part 2: Creating a WordPress custom post type

Now the code.

Step 1: Register your custom post type

You register a post type with register_post_type(), and it has to run on the init hook. Register earlier and WordPress isn’t ready; the docs are blunt that registration should not be hooked before init.

For a quick test you can drop this in your theme’s functions.php, but for anything real, put it in a small plugin. Post types belong to the content, not the design, and you don’t want them vanishing the day someone switches themes. Here’s a fully labelled example:

PHP
<?php
/**
 * Register a custom post type called "custom-post-type".
 */
function custom_post_type() {
    // Define the labels for the custom post type
    $labels = array(
        'name'                  => esc_html__( 'My Custom Post Type', 'textdomain' ), // The plural name for the post type
        'singular_name'         => esc_html__( 'Custom Post', 'textdomain' ), // The singular name for the post type
        'menu_name'             => esc_html__( 'Custom Post Type', 'textdomain' ), // The name that appears in the WordPress admin menu
        'all_items'             => esc_html__( 'All Custom Posts', 'textdomain' ), // The label for the all items view
        'add_new'               => esc_html__( 'Add New', 'textdomain' ), // The label for the add new button
        'add_new_item'          => esc_html__( 'Add New Custom Post', 'textdomain' ), // The label for adding a new custom post
        'edit_item'             => esc_html__( 'Edit Custom Post', 'textdomain' ), // The label for editing a custom post
        'new_item'              => esc_html__( 'New Custom Post', 'textdomain' ), // The label for a new custom post
        'view_item'             => esc_html__( 'View Custom Post', 'textdomain' ), // The label for viewing a custom post
        'view_items'            => esc_html__( 'View Custom Posts', 'textdomain' ), // The label for viewing all custom posts
        'search_items'          => esc_html__( 'Search Custom Posts', 'textdomain' ), // The label for searching custom posts
        'not_found'             => esc_html__( 'No custom posts found', 'textdomain' ), // The label for no custom posts found
        'not_found_in_trash'    => esc_html__( 'No custom posts found in trash', 'textdomain' ), // The label for no custom posts found in trash
        'parent_item_colon'     => esc_html__( 'Parent Custom Post:', 'textdomain' ), // The label for the parent custom post (hierarchical only)
        'featured_image'        => esc_html__( 'Custom Post Image', 'textdomain' ), // The label for the featured image
        'set_featured_image'    => esc_html__( 'Set custom post image', 'textdomain' ), // The label for setting the featured image
        'remove_featured_image' => esc_html__( 'Remove custom post image', 'textdomain' ), // The label for removing the featured image
        'use_featured_image'    => esc_html__( 'Use as custom post image', 'textdomain' ), // The label for using the featured image
        'archives'              => esc_html__( 'Custom Post Archives', 'textdomain' ), // The label for the archives page
        'insert_into_item'      => esc_html__( 'Insert into custom post', 'textdomain' ), // The label for inserting into a custom post
        'uploaded_to_this_item' => esc_html__( 'Uploaded to this custom post', 'textdomain' ), // The label for media uploaded to the custom post
        'filter_items_list'     => esc_html__( 'Filter custom posts list', 'textdomain' ), // The label for the filter dropdown
        'items_list_navigation' => esc_html__( 'Custom posts list navigation', 'textdomain' ), // The label for the list navigation
        'items_list'            => esc_html__( 'Custom posts list', 'textdomain' ), // The label for the items list
    );
    // Define the arguments for the custom post type
    $args = array(
        'labels'                => $labels, // Array of labels for the post type
        'description'           => esc_html__( 'Description of the custom post type', 'textdomain' ), // A short description of the post type
        'public'                => true, // Whether the post type should be publicly visible
        'exclude_from_search'   => false, // Whether the post type should be excluded from search results
        'publicly_queryable'    => true, // Whether the post type should be publicly queryable
        'show_ui'               => true, // Whether to display a user interface for the post type in the WordPress admin
        'show_in_menu'          => true, // Whether to display the post type in the WordPress admin menu
        'show_in_nav_menus'     => true, // Whether to display the post type in navigation menus
        'show_in_admin_bar'     => true, // Whether to display the post type in the WordPress admin bar
        'menu_position'         => 5, // The position in the WordPress admin menu where the post type should appear
        'menu_icon'             => 'dashicons-format-aside', // The icon to use in the WordPress admin menu for the post type
        'capability_type'       => 'post', // The type of WordPress capability that should be used to manage the post type
        'map_meta_cap'          => true, // Whether to use WordPress meta capabilities for managing the post type
        'hierarchical'          => false, // Whether the post type should be hierarchical (like pages)
        'supports'              => array( 'title', 'editor', 'thumbnail', 'custom-fields' ), // An array of features that are supported by the post type
        'register_meta_box_cb'  => 'custom_meta_boxes_function', // A function to call when registering custom meta boxes for the post type
        'taxonomies'            => array( 'category', 'post_tag' ), // An array of taxonomy names that should be associated with the post type
        'has_archive'           => true, // Whether the post type should have an archive page
        'rewrite'               => array( 'slug' => 'custom-post-type', 'with_front' => false ), // An array of options for the post type URL rewrite rules
        'query_var'             => true, // Whether to allow the post type to be queried using a URL query variable
        'can_export'            => true, // Whether the post type can be exported to a file
        'delete_with_user'      => false, // Whether posts of this type should be deleted when the user associated with the post is deleted
        'show_in_rest'          => true, // Whether the post type should be exposed in the WordPress REST API
        'rest_base'             => 'custom-post-types', // The base URL for REST API routes for the post type
        'rest_controller_class' => 'WP_REST_Posts_Controller', // The class that should be used as the controller for REST API routes for the post type
    );
    // Register the custom post type with WordPress
    register_post_type( 'custom_post_type', $args );
}
// Add the custom post type to the WordPress init hook
add_action( 'init', 'custom_post_type' );

Swap “My Custom Post Type” and “Custom Post” for your real names, and adjust the $args to taste. Two arguments earn special attention. show_in_rest set to true is what makes the post type available in the block editor, so leave it on unless you have a reason not to. And has_archive plus a custom rewrite slug means WordPress needs fresh rewrite rules before those pretty URLs work.

That last point is the classic gotcha: your new archive returns a 404 until the rewrite rules flush. Don’t flush on every page load, it’s expensive and the docs say never to. Flush once on plugin activation instead:

register_activation_hook( __FILE__, function() { custom_post_type(); flush_rewrite_rules(); } );

Register the type, then flush, then you’re done. Visiting Settings then Permalinks and hitting Save flushes them too, if you’d rather do it by hand while testing.

Step 2: Add fields to your custom post type

A post type gives you a title and an editor. Most real content needs more: a price, a date, an author bio. You can build those meta boxes by hand, but a plugin like Advanced Custom Fields or Custom Post Type UI gets you there faster and is easier to hand off.

These plugins also register custom taxonomies, so you can categorize and tag your content on terms that fit the data instead of forcing everything through the default Categories and Tags.

Step 3: Use your custom post type

Here’s where the old advice usually goes wrong. Because we set show_in_menu to true, your post type does not hide under Posts. It gets its own top-level menu item in the sidebar, labelled with the menu_name you set and sitting at the menu_position you chose.

So look for “Custom Post Type” in the admin menu, click it, and add your first entry the same way you’d write a post.

Conclusion

Custom post types are one of the highest-leverage tools in WordPress: register the type on init, add the fields your content actually needs, and flush rewrite rules once on activation so the archive resolves.

Do that and your portfolio, catalog, or directory stops fighting the blog and starts behaving like the structured content it is. The next entry in the series builds on this with custom taxonomies and meta.

Leave a Comment

Your email address will not be published. Required fields are marked *


Scroll to Top