Your content model needs every post filed under a parent category and one of its children, for example Recipes → Desserts. Editors forget to tick the child, the post goes live, and the archive pages end up wrong. A JavaScript-only lock in the editor is easy to get around. It also often disables Save draft along with Publish, which is the complaint in a couple of recent WordPress StackExchange questions.
The snippet below enforces the rule on the server, at the REST endpoint the block editor saves through. Drafts and pending posts save as usual. Publishing, scheduling or updating a live post fails until the post has a category and at least one of that category’s subcategories. The block editor shows your message in its normal “Publishing failed” notice, so you don’t need any extra JavaScript or a build step.
The snippet
Put it in a small plugin, an mu-plugin (wp-content/mu-plugins/dpc-require-subcategory.php) or your theme’s functions.php.
<?php
/**
* Plugin Name: DPC Require Category + Subcategory to Publish
*/
/**
* True when the list contains a category AND one of its direct children.
*
* @param int[] $term_ids Category term IDs.
*/
function dpc_has_category_and_subcategory( array $term_ids ): bool {
$term_ids = array_values( array_filter( array_map( 'absint', $term_ids ) ) );
foreach ( $term_ids as $term_id ) {
$term = get_term( $term_id, 'category' );
if ( $term instanceof WP_Term && $term->parent && in_array( (int) $term->parent, $term_ids, true ) ) {
return true;
}
}
return false;
}
/**
* Reject REST saves that would make a post public without a category + subcategory.
*
* @param stdClass|WP_Error $prepared_post Post object about to be inserted/updated.
* @param WP_REST_Request $request Current request.
* @return stdClass|WP_Error
*/
function dpc_require_subcategory_on_publish( $prepared_post, WP_REST_Request $request ) {
if ( is_wp_error( $prepared_post ) ) {
return $prepared_post;
}
$post_id = (int) ( $prepared_post->ID ?? 0 );
// Status is only present when the request changes it; otherwise use the stored one.
$status = $prepared_post->post_status ?? ( $post_id ? get_post_status( $post_id ) : 'draft' );
if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {
return $prepared_post; // Drafts, pending and private saves are untouched.
}
$categories = $request->has_param( 'categories' )
? (array) $request->get_param( 'categories' )
: ( $post_id ? wp_get_post_categories( $post_id ) : array() );
if ( dpc_has_category_and_subcategory( $categories ) ) {
return $prepared_post;
}
return new WP_Error(
'dpc_subcategory_required',
esc_html__( 'Select a category and one of its subcategories before publishing.', 'dpc' ),
array( 'status' => 400 )
);
}
add_filter( 'rest_pre_insert_post', 'dpc_require_subcategory_on_publish', 10, 2 );
How it works
rest_pre_insert_postruns on every create or update request to/wp/v2/posts, after WordPress has built the post object and before anything is written. If you return aWP_Error, the save stops and the editor receives a 400 response along with your message.- The status check means the rule only applies when the post is, or is about to become,
publishorfuture. When the editor sendsstatus: draft, the save passes straight through, so Save draft keeps working. - If the request doesn’t include a status, which happens with some updates to a post that’s already live, the stored status is used. Unticking the subcategory and clicking Update on a published post is therefore blocked as well.
- The categories come from the request when the editor sends them, and from the database otherwise.
dpc_has_category_and_subcategory()returns true only when a checked term’s parent is also checked.
Customise it
- Other post types: the hook is dynamic (
rest_pre_insert_{post_type}). Addadd_filter( 'rest_pre_insert_product', 'dpc_require_subcategory_on_publish', 10, 2 );for each type that uses categories. To attach categories to a CPT first, see Attach Category to Custom Post type. - Child only: if a subcategory on its own is enough, change the condition to
$term instanceof WP_Term && $term->parentand drop thein_array()check. - Custom taxonomy: change
'category'inget_term()and read$request->get_param( 'your_rest_base' )andwp_get_post_terms( $post_id, 'your_tax', array( 'fields' => 'ids' ) ). The REST param name is the taxonomy’srest_base. - Private posts: add
'private'to the status list if those need the rule too.
Gotchas
- This covers the block editor and any REST client. Quick Edit, the classic editor and
wp_insert_post()calls from code don’t go through this hook. If you need the rule there too, add a matching check onwp_insert_post_data. - A post saved with no categories gets the default category (usually Uncategorized), which is a top-level term. On its own it never meets the rule.
- The editor’s publish button stays clickable, and the check runs when the post is saved. If you also want a warning in the pre-publish panel, add it as a convenience on top. Keep this server check as the actual enforcement.
- Autosaves go through a different endpoint (
/autosaves), so they aren’t blocked.
If you’d rather not edit functions.php, you can paste the same PHP into Scripts Organizer as a PHP snippet.