Register and use custom WordPress post statuses in your plugin

WordPress ships with seven built-in post statuses: publish, draft, pending, private, trash, auto-draft, and future. For most editorial and e-commerce workflows, these are not enough. A legal review platform might need an under-review status; a job board might need expired; a content agency workflow might need client-approved and ready-to-publish. WordPress lets you register custom post statuses with register_post_status(), but the API has a known gap: custom statuses do not automatically appear in the Gutenberg editor’s status dropdown or in the classic editor’s publish metabox without additional code. They work correctly via $wpdb and wp_update_post(), and they appear on the posts list screen with a custom label. The workaround for the editor is to either use a small JavaScript snippet (for Gutenberg) or an admin_footer-post.php hook (for the classic editor) to inject the custom status into the dropdown. Custom statuses are especially useful in workflows where content moves through defined stages before publishing, combined with the roles and capabilities system to ensure only authorised users can transition to specific statuses. Pair this with the activity logging guide to track who changed a post’s status and when.

Problem: The default WordPress post statuses (draft, pending, published) do not cover your editorial workflow and you need custom statuses like “Under Review” or “Client Approved”.

Solution: Add the following code to your plugin or functions.php file:

// Register custom post statuses
add_action( 'init', 'helloadmin_register_post_statuses' );
function helloadmin_register_post_statuses(): void {
    register_post_status( 'under_review', [
        'label'                     => _x( 'Under Review', 'post status', 'helloadmin' ),
        'label_count'               => _n_noop( 'Under Review <span class="count">(%s)</span>',
                                                'Under Review <span class="count">(%s)</span>' ),
        'public'                    => false,
        'exclude_from_search'       => true,
        'show_in_admin_all_list'    => true,
        'show_in_admin_status_list' => true,
    ] );

    register_post_status( 'client_approved', [
        'label'                     => _x( 'Client Approved', 'post status', 'helloadmin' ),
        'label_count'               => _n_noop( 'Client Approved <span class="count">(%s)</span>',
                                                'Client Approved <span class="count">(%s)</span>' ),
        'public'                    => false,
        'exclude_from_search'       => true,
        'show_in_admin_all_list'    => true,
        'show_in_admin_status_list' => true,
    ] );
}

// Inject custom statuses into the classic editor dropdown
add_action( 'admin_footer-post.php',     'helloadmin_inject_status_js' );
add_action( 'admin_footer-post-new.php', 'helloadmin_inject_status_js' );
function helloadmin_inject_status_js(): void {
    global $post;
    if ( ! $post ) {
        return;
    }
    $statuses = [
        'under_review'   => __( 'Under Review', 'helloadmin' ),
        'client_approved' => __( 'Client Approved', 'helloadmin' ),
    ];
    $current = esc_js( $post->post_status );
    echo '<script>';
    foreach ( $statuses as $slug => $label ) {
        $slug  = esc_js( $slug );
        $label = esc_js( $label );
        echo "jQuery(document).ready(function(\$){
            \$('select#post_status').append('<option value="{$slug}">{$label}</option>');
            if ('{$current}' === '{$slug}') {
                \$('select#post_status').val('{$slug}');
                \$('#post-status-display').text('{$label}');
            }
        });";
    }
    echo '</script>';
}

// Set a custom status programmatically
// wp_update_post( [ 'ID' => $post_id, 'post_status' => 'under_review' ] );

// Query posts by custom status
// $posts = get_posts( [ 'post_status' => 'under_review', 'numberposts' => -1 ] );

NOTE: Posts with a custom non-public status are not visible on the front end and do not appear in search results, which is the correct behaviour for draft-like workflow statuses. If you need a status that is visible on the front end (like a “Featured” status), set ‘public’ => true — but be aware that this means the post appears in all standard WP_Query loops and archives. When querying by multiple statuses in WP_Query, pass an array: ‘post_status’ => [ ‘under_review’, ‘draft’ ].