WordPress ships with nine built-in post statuses — publish, future, draft, pending, private, trash, auto-draft, inherit, and request-* — but many editorial teams need additional workflow states such as In Review, Approved, Needs Revision, or Ready to Publish. Custom post statuses are registered with register_post_status( $status_slug, $args ) called on the init hook, and the registered slug is stored in wp_posts.post_status as plain text, the same column used by built-in statuses. The $args array controls how WordPress handles the status: 'label' is the human-readable name; 'public' determines whether posts in this status are publicly visible (set to false for pre-publication workflow states); 'show_in_admin_all_list' and 'show_in_admin_status_list' control whether posts appear in the admin list and in the status filter tabs above the list; 'label_count' takes a _n_noop() translation-ready singular/plural pair used in the status tab count. Custom statuses do not appear in the classic editor’s Publish box Status dropdown automatically — you must inject the <option> tags into the dropdown with a JavaScript snippet on post.php, or use the REST API / WP-CLI to set them. In the Block Editor (Gutenberg), the post status control is managed by the @wordpress/editor store — custom statuses added to that store via wp.data.dispatch( 'core/editor' ).editPost({ status: 'in-review' }) update the save payload. WordPress’s REST API already accepts any registered custom status in a PATCH request to /wp/v2/posts/{id} with { "status": "custom-slug" } — the status just has to be registered server-side. Custom statuses integrate with capability checks: capability-protected statuses can gate status transitions behind custom roles using 'capability_type' and map_meta_cap filters. The Custom User Roles post covered capability architecture; custom post statuses build on top of that by adding state-machine semantics to the editorial lifecycle.
Problem: A news site with an editor and three contributors needs posts to move through Submitted → Needs Revision → Approved → Published states. Contributors must not be able to publish directly; editors must see a dedicated status filter in the admin list and receive an email when a post is approved and ready to publish.
Solution: Register three custom statuses on init, add them to the Block Editor toolbar via a short JavaScript snippet, gate status transitions with a transition_post_status action, and notify the editor by email when Approved is set.
// ── Register custom editorial statuses ────────────────────────────────────────
add_action( 'init', 'editorial_register_post_statuses' );
function editorial_register_post_statuses(): void {
$statuses = [
'submitted' => __( 'Submitted', 'editorial' ),
'needs-revision' => __( 'Needs Revision', 'editorial' ),
'approved' => __( 'Approved', 'editorial' ),
];
foreach ( $statuses as $slug => $label ) {
register_post_status( $slug, [
'label' => $label,
'public' => false,
'exclude_from_search' => true,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
// translators: %s = count of posts with this status
'label_count' => _n_noop(
$label . ' (%s)',
$label . ' (%s)',
'editorial'
),
] );
}
}
// ── Gate status transitions by capability ─────────────────────────────────────
add_action( 'transition_post_status', 'editorial_gate_status_transition', 10, 3 );
function editorial_gate_status_transition( string $new, string $old, WP_Post $post ): void {
if ( 'approved' !== $new ) return;
// Only editors and above may set "approved"
if ( ! current_user_can( 'publish_posts' ) ) {
// Revert: prevent the save by redirecting with an error notice
wp_die(
esc_html__( 'You do not have permission to approve posts.', 'editorial' ),
403
);
}
// Notify all editors when a post is approved
$editors = get_users( [ 'role' => 'editor', 'fields' => 'user_email' ] );
if ( empty( $editors ) ) return;
wp_mail(
$editors,
sprintf(
/* translators: %s: post title */
__( '[%s] Post approved and ready to publish', 'editorial' ),
get_bloginfo( 'name' )
),
sprintf(
/* translators: 1: post title, 2: edit URL */
__( 'The post "%1$s" has been approved.
Edit: %2$s', 'editorial' ),
wp_strip_all_tags( $post->post_title ),
esc_url( get_edit_post_link( $post->ID, 'raw' ) )
)
);
}
// ── Prevent contributors from publishing directly ──────────────────────────────
add_filter( 'user_has_cap', 'editorial_limit_contributor_publish', 10, 4 );
function editorial_limit_contributor_publish( array $allcaps, array $caps, array $args, WP_User $user ): array {
if ( ! in_array( 'contributor', $user->roles, true ) ) return $allcaps;
$allcaps['publish_posts'] = false;
return $allcaps;
}
// Enqueue via wp_enqueue_script on 'enqueue_block_editor_assets'
// Adds custom statuses to the Block Editor save payload options
( function () {
const { dispatch, select } = wp.data;
const { addFilter } = wp.hooks;
// Register custom statuses in the @wordpress/editor store so the
// document status dropdown recognises them
const CUSTOM_STATUSES = [
{ slug: 'submitted', label: 'Submitted' },
{ slug: 'needs-revision', label: 'Needs Revision' },
{ slug: 'approved', label: 'Approved' },
];
// Expose as a global so wp.apiRequest and other integrations can read them
window.editorialCustomStatuses = CUSTOM_STATUSES;
// Patch the post-status select in the pre-publish panel
addFilter(
'editor.PostStatusInfo',
'editorial/custom-statuses',
( OriginalComponent ) => ( props ) => {
return window.wp.element.createElement(
window.wp.element.Fragment,
null,
window.wp.element.createElement( OriginalComponent, props ),
CUSTOM_STATUSES.map( ( { slug, label } ) =>
window.wp.element.createElement(
'button',
{
key: slug,
className: 'editorial-status-btn components-button is-tertiary',
onClick: () => dispatch( 'core/editor' ).editPost( { status: slug } ),
style: { display: 'block', marginTop: '4px' },
},
`Set: ${ label }`
)
)
);
}
);
} )();
NOTE: Custom post statuses registered server-side are visible to the WordPress REST API automatically — a PATCH request to /wp/v2/posts/{id} with { "status": "approved" } sets the status correctly without any additional registration. However, the Block Editor UI does not auto-discover custom statuses — the JavaScript snippet above is required to expose the status options in the editor. Also, posts in custom non-public statuses do not appear in WP_Query results by default (the default post_status is 'publish') — when querying for custom-status posts in admin contexts, explicitly pass 'post_status' => 'approved' or an array of statuses. Posts in custom statuses are also excluded from the public-facing site’s RSS feed and archives automatically because 'public' => false is set in the registration arguments.