Post Status

The eight built-in post statuses, what each one means, and how register_post_status() adds your own.

Syntaxregister_post_status( string $post_status, array|string $args = array() ): object

A post's status lives in the post_status column of wp_posts and tells WordPress how to treat the row: whether to serve it on the front end, list it in the admin, or ignore it entirely. Editorial workflow in core is nothing more than moving a row between statuses.

The eight built-ins

StatusMeaning
publishLive and visible to everyone.
futureScheduled; wp_cron flips it to publish at post_date.
draftUnfinished, visible only to users who can edit it.
pendingSubmitted for review, awaiting someone with publish_posts.
privatePublished but readable only by users with read_private_posts.
trashDeleted from the UI, still in the table.
auto-draftA blank row created the moment you open "Add New"; core deletes week-old ones on a daily cron event.
inheritUsed by attachments and revisions, which take their status from the parent.

Reading and querying

get_post_status( $post ) is the shorthand for get_post( $post )->post_status, with the difference that it resolves inherit to the parent's status for you. In WP_Query, 'post_status' => 'any' means every status except those registered with exclude_from_search — so trash and auto-draft are still left out.

Custom statuses

register_post_status() runs on init and takes flags such as public, internal, protected, private, exclude_from_search, show_in_admin_all_list and show_in_admin_status_list. Every visibility flag defaults to null; if you set none, the status becomes internal.

Example

Example · php
<?php
/**
 * An "archived" status: not public, but still listed in the admin
 * so editors can find old material.
 */
function acme_register_archived_status() {
    register_post_status(
        'archived', // max 20 characters, or the value will not save
        array(
            'label'                     => _x( 'Archived', 'post status', 'acme' ),
            // _n_noop() registers the singular/plural strings without translating
            // them yet; the admin list table fills in the count later.
            'label_count'               => _n_noop(
                'Archived <span class="count">(%s)</span>',
                'Archived <span class="count">(%s)</span>',
                'acme'
            ),
            'public'                    => false, // not served on the front end
            'internal'                  => false, // but not hidden from core either
            'exclude_from_search'       => true,  // keeps it out of 'post_status' => 'any'
            'show_in_admin_all_list'    => false, // hide from the "All" tab
            'show_in_admin_status_list' => true,  // add the "Archived (3)" link
        )
    );
}
add_action( 'init', 'acme_register_archived_status' );

// Reading a status. These two are equivalent for a normal post; the first
// also resolves 'inherit' to the parent's status, but only for attachments —
// revisions get no such treatment and return 'inherit' as stored.
$status = get_post_status( 42 );
$status = get_post( 42 )->post_status;

When to use it

  • Adding an "archived" state so old content leaves the front end without being deleted
  • Sending a notification or purging a CDN cache the first time a post is published
  • Building an editorial queue that lists drafts and pending submissions together
  • Working out why a scheduled post is stuck showing the status "future"

More examples

Acting on a status transition

The correct place to hang side effects such as notifications, cache purges or webhook calls. The three hooks are the same event at different levels of specificity; use the narrow ones when you only care about one transition, and note the $new_status === $old_status guard the broad one needs.

Example · php
/**
 * transition_post_status fires on every save, including saves where the
 * status did not change — hence the first guard.
 *
 * @param string  $new_status
 * @param string  $old_status
 * @param WP_Post $post
 */
function acme_notify_on_publish( $new_status, $old_status, $post ) {
    if ( $new_status === $old_status ) {
        return; // an update, not a transition
    }

    if ( 'publish' !== $new_status || 'book' !== $post->post_type ) {
        return;
    }

    wp_mail(
        get_option( 'admin_email' ),
        sprintf( 'Published: %s', $post->post_title ),
        get_permalink( $post )
    );
}
add_action( 'transition_post_status', 'acme_notify_on_publish', 10, 3 );

// Core also fires two narrower dynamic hooks for the same event:
//   {$old_status}_to_{$new_status}   e.g. draft_to_publish   (1 arg: $post)
//   {$new_status}_{$post->post_type} e.g. publish_book       (2 args: $post_id, $post)
add_action( 'draft_to_publish', 'acme_handle_first_publish' );

Querying by status

Use this when building an editorial dashboard or an admin list-table filter. The trap is assuming 'any' means all: it silently omits trash, auto-draft, and any custom status you gave exclude_from_search.

Example · php
// A single status.
$drafts = new WP_Query( array(
    'post_type'   => 'book',
    'post_status' => 'draft',
) );

// Several. Trash and auto-draft must be named explicitly — 'any' excludes
// every status registered with exclude_from_search.
$review_queue = new WP_Query( array(
    'post_type'      => 'book',
    'post_status'    => array( 'draft', 'pending', 'archived' ),
    'posts_per_page' => 20,
) );

// Everything the site considers listable.
$all = new WP_Query( array(
    'post_type'   => 'book',
    'post_status' => 'any',
) );

// The registered status objects themselves, for building a filter UI.
$statuses = get_post_stati(
    array( 'show_in_admin_status_list' => true ),
    'objects' // 'names' (default) returns the slugs instead
);

Making a custom status usable in the classic editor

The workaround for Trac #12706, and the reason custom statuses are more work than they look. In the block editor there is no such dropdown to patch — you register the status server-side and add the control with a plugin sidebar in JavaScript instead.

Example · php
/**
 * register_post_status() does not touch the editor UI, so add the option
 * to the status dropdown and keep it selected after save.
 */
function acme_add_archived_to_dropdown() {
    global $post;

    if ( ! $post || 'book' !== $post->post_type ) {
        return;
    }

    $selected = ( 'archived' === $post->post_status ) ? ' selected="selected"' : '';

    // Injected into the existing <select id="post_status">.
    printf(
        '<script>jQuery(function($){'
        . '$("select#post_status").append(\'<option value="archived"%s>%s</option>\');'
        . '});</script>',
        esc_js( $selected ),
        esc_js( __( 'Archived', 'acme' ) )
    );
}
add_action( 'post_submitbox_misc_actions', 'acme_add_archived_to_dropdown' );

Discussion

  • Be the first to comment on this lesson.