WordPress has built-in content types: posts and pages. A custom post type (CPT) is a new kind of content with its own admin menu and URLs: products, events, recipes, courses, portfolio items.
Register one
<?php
add_action("init", function (): void {
register_post_type("recipe", [
"labels" => [
"name" => "Recipes",
"singular_name" => "Recipe",
"add_new_item" => "Add New Recipe",
],
"public" => true,
"has_archive" => true,
"show_in_rest" => true, // block editor and REST API
"menu_icon" => "dashicons-carrot",
"supports" => ["title", "editor", "thumbnail", "excerpt"],
"rewrite" => ["slug" => "recipes"],
]);
});
do_action("init"); // in a real plugin WordPress fires this for you
var_dump(post_type_exists("recipe"));
echo get_post_type_object("recipe")->labels->singular_name;
bool(true)
Recipe
(The do_action("init") call here only makes the demo run on its own. In a real plugin you do not call it. WordPress runs init once per request.)
The important arguments
| Argument | Meaning |
|---|---|
labels | Names shown in the admin |
public | Visible in the admin and on the front end |
has_archive | Creates an archive page (/recipes/) |
show_in_rest | Required for the block editor and the REST API |
supports | Features: title, editor, thumbnail, excerpt, custom-fields… |
rewrite | The URL base (/recipes/chocolate-cake/) |
menu_icon | A Dashicons name or an image URL |
capability_type | Which permissions apply (default post) |
hierarchical | true for page-like parent and child items |
Naming rules
- The post type key is lowercase, up to 20 characters, with letters, numbers, underscores and dashes.
- Prefix it if there is a risk of a clash (
myplugin_recipe). - Do not use reserved names such as
post,page,attachment,revision,nav_menu_item.
Register on init
CPTs must be registered on the init hook, early enough that WordPress knows about them when it parses the URL.
Flush rewrite rules, but only once
Adding a post type with a new URL slug needs WordPress to rebuild its URL rules. Do this only when the plugin is activated, never on every request (it is slow).
<?php
register_activation_hook(__FILE__, function (): void {
my_register_recipe_post_type(); // register first
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, "flush_rewrite_rules");
In development, you can refresh the rules by visiting Settings, Permalinks and pressing Save.
A custom taxonomy
A taxonomy groups content (like categories and tags).
<?php
add_action("init", function (): void {
register_taxonomy("cuisine", ["recipe"], [
"labels" => ["name" => "Cuisines", "singular_name" => "Cuisine"],
"hierarchical" => true,
"show_in_rest" => true,
"rewrite" => ["slug" => "cuisine"],
]);
});
do_action("init");
var_dump(taxonomy_exists("cuisine"));
bool(true)
Display them: query the CPT
<?php
$recipes = new WP_Query(["post_type" => "recipe", "posts_per_page" => 6]);
if ($recipes->have_posts()) :
while ($recipes->have_posts()) : $recipes->the_post();
the_title("<h2>", "</h2>");
endwhile;
wp_reset_postdata();
endif;
WordPress looks for single-recipe.php and archive-recipe.php in the theme (see WordPress Template PHP).
Custom fields
For extra data (cooking time, price), use post meta. Plugins like Advanced Custom Fields give you a friendly UI for it. In code: get_post_meta() and update_post_meta() (see WordPress Functions).
CPT in a plugin, not in a theme
If the content should survive when the site changes theme, register the post type in a plugin. A theme-registered CPT disappears when the theme is switched.
The same tool for every content type
register_post_type() is how WordPress turns into a content management system for anything.