Custom Post Statuses for Editorial Workflows in WordPress

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 SubmittedNeeds RevisionApprovedPublished 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.