EthicsComputerCourses
Join training +91 90532 10052

Custom Post Types & Taxonomies

WordPress · Lesson 24 of 30 Advanced 7 min read Updated

By the end of this lesson you will add a new kind of content called Courses to WordPress, with its own menu, its own URL, a "Level" grouping and extra fields such as fee and duration. This is how WordPress grows from a blog into a real business website.

Why custom post types?

WordPress comes with posts and pages. But a coaching centre has courses, a clinic has doctors, a real estate agent has properties. Squeezing these into posts makes a mess: courses would mix with news, appear in the blog list and share the same categories.

A custom post type (CPT) is a new content type that you define. It is like adding a new register in the school office: there is already a register for notices (posts) and one for fixed information (pages); now you add a separate register just for courses, with its own columns.

A custom taxonomy is a new way to group items, like categories but your own. For courses, a taxonomy called Level could hold "Foundation (Class 9-10)", "Senior Secondary (Class 11-12)" and "Competitive Exams".

Step 1: Register the post type

Put this code in a plugin (not the theme, so courses survive a theme change). Create wp-content/plugins/bright-courses/bright-courses.php with a plugin header as in the previous lesson, then add:

PHP
function bf_register_course_type() {
    register_post_type( 'course', [
        'labels'       => [
            'name'          => 'Courses',
            'singular_name' => 'Course',
            'add_new_item'  => 'Add New Course',
            'edit_item'     => 'Edit Course',
            'all_items'     => 'All Courses',
        ],
        'public'       => true,
        'has_archive'  => true,
        'rewrite'      => [ 'slug' => 'courses' ],
        'menu_icon'    => 'dashicons-welcome-learn-more',
        'supports'     => [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ],
        'show_in_rest' => true,
    ] );
}
add_action( 'init', 'bf_register_course_type' );

What the main options mean:

OptionEffect
'course'The internal name (max 20 characters, lowercase, no spaces)
publicVisible on the site and in the admin
has_archiveCreates a list page at /courses/
rewriteThe URL part: single courses live at /courses/{slug}/
menu_iconA Dashicons icon name for the admin menu
supportsWhich editor features are available
show_in_restRequired for the block editor and the REST API

Step 2: Activate the plugin. A new Courses menu with a graduation-cap icon appears in the admin.

Step 3: Go to Settings > Permalinks and click Save Changes. This rebuilds the URL rules so /courses/ works. (You will automate this below.)

Step 4: Add a course called "JEE Main Maths Crash Course" and publish it. Visit /courses/jee-main-maths-crash-course/. It opens with your theme's single template.

Step 2: Register a taxonomy

PHP
function bf_register_course_level() {
    register_taxonomy( 'course_level', 'course', [
        'labels'            => [
            'name'          => 'Levels',
            'singular_name' => 'Level',
        ],
        'hierarchical'      => true,  // behaves like categories (tick boxes)
        'show_admin_column' => true,  // shows a Level column in All Courses
        'rewrite'           => [ 'slug' => 'level' ],
        'show_in_rest'      => true,
    ] );
}
add_action( 'init', 'bf_register_course_level' );

hierarchical => true gives tick boxes and parent levels, like categories. Set it to false for tag-style free labels, for example "Subjects: physics, chemistry".

After saving permalinks again, a Levels item appears under Courses. Add three levels and tick one on each course. The list of all foundation courses now lives at /level/foundation/.

Step 3: Extra fields with post meta

A course needs more than a title and description: fee, duration, batch timing. WordPress stores extra fields as post meta, small key-value pairs attached to a post. Register them so WordPress knows their type and exposes them to the editor:

PHP
function bf_register_course_meta() {
    foreach ( [ 'course_fee', 'course_duration', 'course_timing' ] as $key ) {
        register_post_meta( 'course', $key, [
            'type'              => 'string',
            'single'            => true,
            'show_in_rest'      => true,
            'sanitize_callback' => 'sanitize_text_field',
        ] );
    }
}
add_action( 'init', 'bf_register_course_meta' );

To fill these fields without code, open a course, then the three-dot menu > Preferences > General, and switch on Custom fields. A Custom Fields box appears below the editor where you add course_fee = "₹18,000 (example)" and so on. In the project lesson you will build a friendlier way.

Read meta in PHP with get_post_meta():

PHP
$fee = get_post_meta( get_the_ID(), 'course_fee', true );
echo '<p>Fee: ' . esc_html( $fee ) . '</p>';
Output
<p>Fee: ₹18,000 (example)</p>

The third argument true asks for a single value instead of an array.

Step 4: Show the fields with blocks

Since WordPress 6.5, the Block Bindings API lets a normal block show a meta value. In your block theme, create templates/single-course.html (the template hierarchy picks it for courses) and bind a paragraph to the fee:

HTML
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
  <!-- wp:post-title {"level":1} /-->
  <!-- wp:post-terms {"term":"course_level"} /-->
  <!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"course_fee"}}}}} -->
  <p></p>
  <!-- /wp:paragraph -->
  <!-- wp:post-content /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

Open a course. Under the title you see its level, then the fee from post meta, then the description. Binding works because the meta was registered with show_in_rest.

Step 5: List courses with a Query Loop

On any page, add a Query Loop block. In its settings, turn off "Inherit query from template" (in newer versions, set Query type to custom) and choose Post type: Courses. Add Title, Featured Image and Excerpt inside. Now your homepage can show the latest courses.

In markup, the important part is the query:

HTML
<!-- wp:query {"query":{"postType":"course","perPage":6,"order":"asc","orderBy":"title","inherit":false}} -->

Flush rewrite rules on activation

Clicking Save on Permalinks is easy to forget. Do it automatically when the plugin is activated:

PHP
register_activation_hook( __FILE__, function () {
    bf_register_course_type();
    bf_register_course_level();
    flush_rewrite_rules();
} );

Flushing is slow, so do it only on activation (and on deactivation), never on every page load.

Checking with WP-CLI

Terminal
wp post list --post_type=course --fields=ID,post_title,post_status
Output
+----+-----------------------------+-------------+
| ID | post_title                  | post_status |
+----+-----------------------------+-------------+
| 31 | JEE Main Maths Crash Course | publish     |
| 29 | Class 10 Science Foundation | publish     |
+----+-----------------------------+-------------+

Common mistakes

  • Registering the CPT in the theme. Switch themes and your courses vanish from the admin (the data stays, but nothing shows it).
  • Forgetting show_in_rest. The editor falls back to the classic editor and block bindings cannot read the meta.
  • "Page not found" on new course URLs. Save permalinks or flush rewrite rules on activation.
  • Reserved or clashing names. Avoid names like post, page, type or order; prefix if unsure (bf_course).
  • Storing complex data in many meta keys when a taxonomy fits. Use taxonomies for grouping and filtering, meta for single facts.

Key points

  • register_post_type() on the init hook creates a new content type with its own menu and URLs.
  • show_in_rest => true enables the block editor and the REST API for the type.
  • register_taxonomy() adds custom grouping; hierarchical chooses category-style or tag-style.
  • register_post_meta() defines extra fields; get_post_meta() reads them; block bindings show them in templates.
  • single-{post-type} and archive-{post-type} templates control the layout.
  • Put CPTs in a plugin and flush rewrite rules on activation.

Exercise

Create a custom post type teacher with an archive at /teachers/, the dashicons-groups admin icon, support for title, editor and thumbnail, and a non-hierarchical taxonomy subject attached to it. Register a meta field experience_years as an integer. Add two teachers and check /teachers/.

Show solution
PHP
add_action( 'init', function () {
    register_post_type( 'teacher', [
        'labels'       => [ 'name' => 'Teachers', 'singular_name' => 'Teacher' ],
        'public'       => true,
        'has_archive'  => true,
        'rewrite'      => [ 'slug' => 'teachers' ],
        'menu_icon'    => 'dashicons-groups',
        'supports'     => [ 'title', 'editor', 'thumbnail', 'custom-fields' ],
        'show_in_rest' => true,
    ] );
    register_taxonomy( 'subject', 'teacher', [
        'labels'       => [ 'name' => 'Subjects', 'singular_name' => 'Subject' ],
        'hierarchical' => false,
        'show_in_rest' => true,
    ] );
    register_post_meta( 'teacher', 'experience_years', [
        'type'              => 'integer',
        'single'            => true,
        'show_in_rest'      => true,
        'sanitize_callback' => 'absint',
    ] );
} );

Save permalinks once, add two teachers, and open /teachers/. absint turns the value into a whole number that is zero or more.

Quick Quiz

1. Why should a custom post type be registered in a plugin rather than a theme?

2. What does show_in_rest => true enable?

3. New course URLs show "Page not found". What is the usual fix?

4. Which taxonomy setting gives category-style tick boxes and parent items?

Learn WordPress with a mentor

Free lessons get you started. Our WordPress & Website Building training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call