Как добавить свой статус записи в WordPress

В WordPress пост-статусы — это боль. Зарегистрировать кастомный статус просто, но интегрировать его в UI — сплошной костыль.

Самая большая проблема: на странице редактирования поста твой статус просто не появляется. WordPress заточен под свои publish, draft, pending и т.д. Все остальные надо вручную внедрять в интерфейс и контролировать при сохранении.

Что приходится делать:

  1. Добавлять кастомные статусы в выпадающий список через JS (обычно jQuery).
  2. Следить за сохранением: WordPress может сам поменять твой статус на дефолтный. Поэтому иногда нужно хукаться на transition_post_status, wp_insert_post_data и т.п.
  3. Контролировать, когда и как показывать статус — вручную. Это касается и фильтрации, и прав доступа, и логики отображения.

Люди пробуют, страдают, делают. WordPress не предусматривает нормальной поддержки кастомных статусов в админке.

Если хочешь использовать кастомные статусы:

  • готовься писать JS, PHP и фильтры руками;
  • тестировать каждый кейс отдельно;
  • и задумываться, может, лучше вообще обойтись без статусов — через мета, таксономии, или свой флоу.

Это не та часть WP, которую можно "просто добавить". Однако, если ты готов к потенциальным трудностям, чтобы сделать все "красиво", то читай далее smile

Класс для регистрации статуса поста

Этот класс - это попытка завернуть всю вышеописанную рутинку по регистрации статуса в объект с параметрами. Разумеется он не всегда подойдет, но его можно взять за основу и поправить код как нужно.

/**
 * Wrapper class for registering custom post statuses in WordPress.
 *
 * @version 1.0.0
 */
class Kama_Post_Status_Registrator {

	private string $slug;

	private string $label;

	/**
	 * Additional arguments for the post status.
	 *
	 * @see register_post_status()
	 */
	private array $args;

	/**
	 * @param string $slug  The unique slug for the post status.
	 * @param array  $args  Additional arguments {@see register_post_status()}.
	 */
	public function __construct( string $slug, array $args = [] ) {
		$this->slug  = $slug;
		$this->label = $args['label'] ?? ucfirst( $slug );
		$this->args  = wp_parse_args( $args, [
			'label'       => $this->label,
			'label_count' => _n_noop( "{$this->label} <span class=\"count\">(%s)</span>", "{$this->label} <span class=\"count\">(%s)</span>" ),
			'public'      => true,
			'show_in_admin_status_list' => true,
			'show_in_admin_all_list'    => true,
		] );
	}

	public function register(): void {
		add_action( 'init', [ $this, '_register' ] );
	}

	public function _register(): void {
		register_post_status( $this->slug, $this->args );

		$this->classic_editor_hooks();

		add_action( 'admin_print_footer_scripts-edit.php',     [ $this, '_quick_edit_dropdown' ] );
		add_filter( 'display_post_states',                     [ $this, '_show_status_next_to_post_title' ], 10, 2 );
	}

	private function classic_editor_hooks(): void {
		add_action( 'post_submitbox_misc_actions', [ $this, '_editor_dropdown' ] );
		add_filter( 'wp_insert_post_data',         [ $this, '_fix_new_post_status' ], 10, 4 );
	}

	public function _editor_dropdown( $post ): void {
		$option = sprintf( '<option value="%s">%s</option>', esc_attr( $this->slug ), esc_html( $this->label ) );
		?>
		<script>
			document.addEventListener( 'DOMContentLoaded', function(){
				const statusSelect = document.querySelector( 'select#post_status' );
				statusSelect.insertAdjacentHTML( 'beforeend', <?= wp_json_encode( $option ) ?> );
				<?php if ( $this->slug === $post->post_status ) { ?>
				document.querySelector( '#post-status-display' ).textContent = <?= wp_json_encode( $this->label ) ?>;
				statusSelect.value = <?= wp_json_encode( $this->slug ) ?>;
				<?php } ?>
			} );
		</script>
		<?php
	}

	public function _quick_edit_dropdown(): void {
		$option = sprintf( '<option value="%s">%s</option>', esc_attr( $this->slug ), esc_html( $this->label ) );
		?>
		<script>
		document.addEventListener( 'DOMContentLoaded', function(){
			document.querySelector( 'select[name="_status"]' ).insertAdjacentHTML( 'beforeend', <?= wp_json_encode( $option ) ?> );
		});
		</script>
		<?php
	}

	public function _show_status_next_to_post_title( $states, $post ) {
		if ( $this->slug === $post->post_status && $this->slug !== get_query_var( 'post_status' ) ) {
			$states[] = $this->label;
		}

		return $states;
	}

	/**
	 * When creating a new post with a custom status and clicking "Publish",
	 * the post gets "publish" status anyway. This fixes that behavior.
	 * Tested on WP 6.8.
	 */
	public function _fix_new_post_status( array $data, array $_, $__, $update ): array {
		/**
		 * NOTE: We must use the $_REQUEST superglobal to determine the real
		 * REQUEST post_status, because at the moment WP hard changes the
		 * $data['post_status'] and $_POST['post_status'] to "publish" when
		 * we push "Publish" button for a new post (tested on WP 6.8).
		 * @see _wp_translate_postdata()
		 */
		$status = $_REQUEST['post_status'] ?? '';

		/**
		 * NOTE: We should do this on update only because on add-new-post an 'auto-draft' created and then it is updated.
		 */
		$is_fix_needed = $update && is_admin() && ! in_array( $status, [ 'auto-draft', 'inherit' ] );
		if ( $is_fix_needed && $status === $this->slug ) {
			$data['post_status'] = $this->slug;
		}

		return $data;
	}

}

Использование:

$registrator = new Kama_Post_Status_Registrator( 'featured' );
$registrator->register();

Или с параметрами:

$registrator = new Kama_Post_Status_Registrator( 'featured', [
	'label' => __( 'Featured', 'dom' ),
	'public' => true,
	'show_in_admin_status_list' => false,
	'show_in_admin_all_list'    => false,
] );
$registrator->register();

Шаг за шагом

Регистрация статуса

Для начала — регистрируем собственный статус записи. Это делается аналогично регистрации типа записи или таксономии, только используется функция register_post_status():

add_action( 'init', 'register_mynew_post_status' );

function register_mynew_post_status() {
	register_post_status( 'featured', [
		'label'       => 'Featured',
		'label_count' => _n_noop( 'Featured <span class="count">(%s)</span>', 'Featured <span class="count">(%s)</span>' ),
		'public'      => true,
	] );
}

Добавляем этот код в functions.php вашей темы или в плагин. И... Ничего не произошло!

Но register_post_status() что-то все же делает — теперь статус доступен в WordPress. Однако, чтобы он появился в админке, нужно выполнить ещё несколько шагов.

Отображение в списке статусов в админке

Чтобы статус отображался в списке фильтра по статусам в админке, убедитесь, что:

  1. Есть хотя бы один пост с этим статусом.
  2. Вы включили параметры для register_post_status():
    register_post_status( 'featured', [
    	...
    	'show_in_admin_status_list'=> true,
    	'show_in_admin_all_list'   => true,
    ] );

Добавление статуса в метабокс редактирования записи (Classic Editor)

WordPress по умолчанию не отображает кастомные статусы в выпадающем списке. Решение — jQuery-костыль:

add_action( 'post_submitbox_misc_actions', 'add_mynew_post_status_to_edit_post_list' );
function add_mynew_post_status_to_edit_post_list( $post ) {
	?>
	<script>
	document.addEventListener( 'DOMContentLoaded', function(){
		const statusSelect = document.querySelector( 'select#post_status' );
		statusSelect.insertAdjacentHTML( 'beforeend', '<option value="featured">Featured</option>' );
		<?php if ( 'featured' === $post->post_status ) { ?>
			document.querySelector( '#post-status-display' ).textContent = 'Featured';
			statusSelect.value = 'featured';
		<?php } ?>
	} );
	</script>
	<?php
}

При обновлении. Сохранение статуса работает сразу - нет необходимости делать что-то еще на хуке save_post.

Однако, при добавлении поста, если выбрать свой статус и нажать "Опубликовать", то выбранный статус не сохранится, а превратится в "publish". Чтобы он сохранился нужно еще раз выбрать статус и еще раз обновить пост. См. класс ниже там эта проблема решена.

Проверялось на WP 6.8.

Добавление статуса в Quick Edit

Еще костыль, чтобы добавить статус в меню “Быстрое редактирование”:

add_action( 'admin_footer-edit.php', 'add_mynew_post_status_to_quick_edit' );

function add_mynew_post_status_to_quick_edit() {
	?>
	<script>
	document.addEventListener( 'DOMContentLoaded', function(){
		document.querySelector( 'select[name="_status"]' ).insertAdjacentHTML(
			'beforeend',
			'<option value="featured">Featured</option>'
		);
	});
	</script>
	<?php
}

Свой статус в Quick Edit

Отображение статуса рядом с заголовком записи

Используем хук display_post_states, чтобы отобразить ваш статус в списке записей рядом с заголовками:

add_filter( 'display_post_states', 'show_mynew_post_status_next_to_post_title', 10, 2 );
function show_mynew_post_status_next_to_post_title( $states, $post ) {
	if ( 'featured' === get_query_var( 'post_status' ) ) {
		return $states;
	}

	if ( 'featured' === $post->post_status ) {
		$states[] = 'Featured';
	}

	return $states;
}

Перевод поста в новый статус

Для создания поста с нужным статусом можно использовать wp_insert_post() или wp_update_post():

wp_update_post( [
	'ID'          => 12345,
	'post_status' => 'featured',
] );

Зачем вообще может нужно использовать статусы

Основная причина использования статусов - это то что в запросах (WP_Query, REST) передаёшь post_status и без meta_query получаешь нужный набор записей - это производительнее и удобнее.

Также в админке сразу появляется удобный фильтр постов по статусу.

Чаще всего статусы могут пригодится для произвольных типов записей. Например у нас есть тип записи (донаты) и каждый типа может иметь свои статусы, которые вообще никак не сочетаются с дефолтными: draft, publish, private:

Статусы типа записи "донаты": submited, funded, refunded, failed

Еще примеры, где статусы постов могут хорошо подойти:

  • Нестандартный тип записи
    Тип записи, который не относится к статьям, а содержит какую-то информацию, например об оплаченных заявках.
    Статусы: submited, funded, failed
    Как работает: Создается заявка, затем она оплачивается или нет.

  • Редакционный флоу
    Статусы: draft -> review -> design-ready -> publish
    Как работает: каждый переход триггерит transition_post_status, нужный отдел видит свои записи через фильтр по статусу, фронт не покажет пост пока статус не "publish".

  • Модерация
    Статусы: pending -> flagged -> awaiting-recheck -> publish|trash
    Как работает: жалоба ставит flagged и убирает материал из публичного запроса, модератор после проверки меняет статус, REST и WP_Query отдают только publish.

  • Платный контент
    Статусы: paywall-pending -> subscription-only -> publish
    Как работает: статья создаётся как "paywall-pending", оплата/крон переводит в "subscription-only", pre_get_posts пропускает статус только для подписчиков, позже можно открыть материал для всех, переведя в статус "publish".

  • Этапы события
    Статусы: registration-open -> event-live -> event-after -> archive
    Как работает: крон по датам меняет статус, видимость страниц события регулируется одним параметром post_status, без meta_query и сравнений дат.

  • Многошаговые заявки
    Статусы: incomplete -> awaiting-payment -> approved -> fulfilled
    Как работает: пользователь сохраняет черновик как "incomplete", платёжный webhook переводит в "awaiting-payment", после поступления денег ставит "funded", менеджер при выполнении услуги переключает на "fulfilled" для отчетности.