Custom Post Types & Taxonomies
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:
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:
| Option | Effect |
|---|---|
'course' | The internal name (max 20 characters, lowercase, no spaces) |
public | Visible on the site and in the admin |
has_archive | Creates a list page at /courses/ |
rewrite | The URL part: single courses live at /courses/{slug}/ |
menu_icon | A Dashicons icon name for the admin menu |
supports | Which editor features are available |
show_in_rest | Required 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
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:
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():
$fee = get_post_meta( get_the_ID(), 'course_fee', true );
echo '<p>Fee: ' . esc_html( $fee ) . '</p>';<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:
<!-- 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:
<!-- 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:
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
wp post list --post_type=course --fields=ID,post_title,post_status+----+-----------------------------+-------------+ | 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,typeororder; 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 theinithook creates a new content type with its own menu and URLs.show_in_rest => trueenables the block editor and the REST API for the type.register_taxonomy()adds custom grouping;hierarchicalchooses 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}andarchive-{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
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?
Content structure should not depend on the current design.
2. What does show_in_rest => true enable?
The block editor talks to WordPress through the REST API.
3. New course URLs show "Page not found". What is the usual fix?
New URL patterns need the rewrite rules rebuilt.
4. Which taxonomy setting gives category-style tick boxes and parent items?
Hierarchical taxonomies behave like categories; non-hierarchical ones behave like tags.
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
