Post Status
The eight built-in post statuses, what each one means, and how register_post_status() adds your own.
register_post_status( string $post_status, array|string $args = array() ): objectA 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
| Status | Meaning |
|---|---|
publish | Live and visible to everyone. |
future | Scheduled; wp_cron flips it to publish at post_date. |
draft | Unfinished, visible only to users who can edit it. |
pending | Submitted for review, awaiting someone with publish_posts. |
private | Published but readable only by users with read_private_posts. |
trash | Deleted from the UI, still in the table. |
auto-draft | A blank row created the moment you open "Add New"; core deletes week-old ones on a daily cron event. |
inherit | Used 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
<?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.
/**
* 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.
// 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.
/**
* 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