Posts and Pages

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Posts and pages are the two content types every WordPress site has from the first install. Both are rows of the wp_posts table, distinguished by the post_type column, and both move through the same set of statuses, from draft to published to trash. This page explains how they differ, what each status means and who can set it, how scheduling really works, the per-post features (sticky, excerpt, featured image, post format, page attributes, template), how a site gets a static front page with a separate news page, and how the trash and revisions are kept and limited.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, theme.json version 3 and WP-CLI 2.12.0, and checked on 2026-10-28. Every WP-CLI result shown below comes from a fresh 7.1.3 installation with the Twenty Twenty-Five theme, then with the Lumen themes. WordPress 7.2 (in beta, planned for 2026-12-08) does not change the status model, the trash or revisions so far. The editor panels described here are those of the block editor; Block Editor for Authors covers the editor itself.

Posts versus pages

Aspect Post (post) Page (page)

Purpose

Dated, time-ordered content: news, announcements, articles

Timeless content: About, Contact, Submissions

Listing

Appears in the blog index, archives (date, category, tag, author) and feeds

Appears in none of them; reached through menus or links

Hierarchy

Flat (not hierarchical)

Hierarchical: a page has a parent, so URLs nest (/about/submissions/)

Taxonomies

Categories and tags

None by default

Specific features

Sticky, post format, excerpt, trackbacks

Parent, order, excerpt only if a plugin adds support

Templates

single.html/single.php by default; custom templates if the theme declares them for post

page.html/page.php, plus any custom page template

Comments

Open by default (Settings > Discussion)

Closed by default (4.3+)

Both share the status workflow, revisions, autosaves, featured images, the author field and the trash. A custom post type such as the Lumen Press book catalogue (lp_book, registered by the lumen-catalog plugin) gets the same statuses automatically and opts into the rest through its supports list (revisions, excerpt, thumbnail, page-attributes, post-formats); see Custom Post Types and Taxonomies.

Lumen Press uses posts for its news blog ("Spring catalogue announced", "Submissions open") and pages for About, Contact and Submissions (a child of About), plus two pages, Home and News, that become the static front page and the posts page later on this page.

A site to try the examples

The commands below run in the WP-CLI container of the MariaDB and WP-CLI Compose stack described in Database Schema, on a fresh 7.1.3 install titled "Lumen Press" at http://lumenpress.test. Local Development Environments compares it with wp-env, Studio and Playground.

Post statuses

The status lives in the post_status column and decides who can see a post and where it is listed. Core registers twelve statuses in create_initial_post_types(); six of them are the editorial workflow, the rest are internal.

Status Editor label Meaning and visibility

draft

Draft

Saved but not ready. Visible only in the admin, to users who can edit it.

pending

Pending

Submitted for review. A Contributor (who has edit_posts but not publish_posts) sees Submit for Review instead of Publish, which sets this status. An Editor or Administrator then publishes it.

future

Scheduled

Has a publish date in the future; WP-Cron publishes it at that time.

publish

Published

Public: visible to everyone, listed in archives, feeds, search, the REST API and the sitemap.

private

Private

Published but visible only to logged-in users with read_private_posts (Editors and Administrators) and to its author. The title is prefixed "Private:".

trash

(Trash)

Removed but recoverable until the trash is emptied. Internal, but listed under the Trash filter.

auto-draft

none

Created when the editor opens a new post, before anything is saved; cleaned up automatically after seven days.

inherit

none

Used by revisions and attachments, which take their effective status from their parent.

request-pending, request-confirmed, request-failed, request-completed

none

Personal-data export and erasure requests (user_request post type), see Privacy and Personal Data.

Password protected is not a status. It is a published post with a value in post_password: the title gets a "Protected:" prefix and the content is replaced by a password form until the visitor enters it. The password is stored in plain text and the cookie (wp-postpass_<hash>) lasts ten days. It is a convenience for sharing proofs with a few reviewers, not access control; anything confidential belongs behind a login (private, or a membership plugin).

The flags of each registered status (get_post_stati( array(), 'objects' )) explain the behaviour: publish is public, future, draft and pending are protected, private is private, and the rest are internal (trash is internal but still has a Trash filter in the list screens). public statuses are shown to everyone, protected ones only to users who can edit the post, private ones to users with the read_private_* capability. The REST API mirrors this: GET /wp/v2/statuses returns the five workflow statuses plus trash to an administrator and only publish to an anonymous visitor, and /wp/v2/posts?status=draft without authentication returns 400.

Status transitions

stateDiagram-v2 direction LR [*] --> auto_draft: editor opened auto_draft --> draft: Save draft auto_draft --> pending: Submit for Review auto_draft --> publish: Publish auto_draft --> future: Schedule draft --> pending: Submit for Review pending --> draft: Back to draft draft --> publish: Publish pending --> publish: Editor publishes draft --> future: Schedule (date in the future) pending --> future: Editor schedules future --> publish: WP-Cron publish_future_post future --> draft: Unschedule draft --> private: Set private publish --> private: Set private private --> publish: Set public publish --> draft: Switch to draft draft --> trash: Move to trash pending --> trash: Move to trash future --> trash: Move to trash publish --> trash: Move to trash private --> trash: Move to trash trash --> draft: Restore (default since 5.6) trash --> [*]: Delete permanently or EMPTY_TRASH_DAYS elapsed auto_draft --> [*]: wp_delete_auto_drafts after 7 days state "auto-draft" as auto_draft

Each change fires three hooks from wp_transition_post_status(): transition_post_status (new status, old status, post), {$old_status}_to_{$new_status} (for example pending_to_publish, the usual place to notify an author that a post went live) and {$new_status}_{$post->post_type} (for example publish_lp_book). A freshly inserted post transitions from the pseudo-status new. Dynamic hook names are explained in Hooks: Actions and Filters.

Custom statuses can be registered with register_post_status() (3.0+), but neither the classic Publish box nor the block editor shows them in the status selector; that work has been open in Trac ticket #12706 for years. Editorial plugins that add "In edit" or "Legal review" states build their own UI on top. For most sites, the five built-in states plus the Pending filter on the Posts screen are enough.

Setting statuses from WP-CLI

wp post create --post_type=post --post_title='Spring catalogue announced' --post_status=draft --porcelain
wp post create --post_type=post --post_title='Submissions open' --post_status=pending --porcelain
wp post create --post_title='Editorial calendar (staff only)' --post_status=private --porcelain
wp post create --post_title='Proofs for reviewers' --post_status=publish \
  --post_password='<a-shared-password>' --porcelain
wp post list --post_status=draft,pending,private --fields=ID,post_title,post_status

WP-CLI does not check capabilities. In the admin they apply: Contributors reach only draft and pending, Authors publish and schedule their own posts, Editors and Administrators everyone’s. The full matrix is in Users, Roles and Profiles.

The status panel in the editor

In the block editor the document settings sidebar (the Post or Page tab, named after the post type) holds the summary of everything on this page:

  • Status: a popover with Draft, Pending, Private, Scheduled and Published, a Password protected option, and, for posts, Sticky. Pending is not offered once a post has been published.

  • Publish: Immediately or a date and time picker. A future date turns the button into Schedule; a past date backdates the post.

  • Slug, Author, Template (with Edit template and Add template on block themes, for users who can edit theme templates), Post format (posts, when the theme supports formats), Featured image, Excerpt, Discussion, and for pages Parent and Order.

  • Move to trash, available once the post has been saved.

The Posts and Pages screens (edit.php, edit.php?post_type=page) list everything with filters per status (All, Mine, Published, Scheduled, Drafts, Pending, Private, Trash), and Quick Edit changes the status, date, password, parent, order, template and sticky flag without opening the editor.

Scheduling

A scheduled post is a post with status future and a post_date in the future. Saving it schedules a single WP-Cron event, publish_future_post, at the post’s GMT date; when it fires, check_and_publish_future_post() checks the date again and calls wp_publish_post().

ID=$(wp post create --post_title='Autumn launch party' --post_status=future \
  --post_date='2030-11-20 18:00:00' --porcelain)
wp post get "$ID" --fields=post_status,post_date,post_date_gmt
wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt,args
Field          Value
post_date      2030-11-20 18:00:00
post_date_gmt  2030-11-20 18:00:00
post_status    future
hook                 next_run_gmt         args
publish_future_post  2030-11-20 18:00:00  [6]

Facts that follow from this design:

  • post_date is local time, post_date_gmt is UTC. The local time comes from Settings > General > Timezone. With timezone_string set to Europe/Madrid, a post scheduled for 2030-01-15 09:00:00 gets a GMT date and a cron time of 08:00:00. Changing the timezone later does not move already scheduled events, so set the timezone before scheduling, and prefer a city over a fixed UTC offset so daylight saving time is handled.

  • A past date publishes at once. wp_insert_post() turns future into publish when the date is not in the future; creating a "scheduled" post dated 2020 returns a published post.

  • "Missed schedule". WP-Cron runs only when someone loads a page (or when a real cron job calls it). On a quiet site, or when loopback requests fail, the post stays future after its time and the admin shows "Missed schedule". The fix is a system cron with DISABLE_WP_CRON, described in WP-Cron and Scheduled Tasks. Late posts can be published by hand:

wp post list --post_status=future --fields=ID,post_title,post_date
wp eval 'check_and_publish_future_post( 28 ); echo get_post_status( 28 ), PHP_EOL;'

The function publishes only posts whose date has passed; for a post still in the future it re-schedules the event, so it is safe to run on every future post.

Sticky posts

A sticky post is kept at the top of the blog index. Stickiness is not a column: the IDs are stored in the sticky_posts option, an array, and stick_post(), unstick_post() and is_sticky() manage it. Only posts can be sticky.

ID=$(wp post create --post_title='Welcome to Lumen Press' --post_status=publish \
  --post_excerpt='Independent books since 2026.' --porcelain)
wp eval "stick_post( $ID ); var_dump( is_sticky( $ID ) );"
wp option get sticky_posts --format=json

WP_Query moves sticky posts to the top only on the posts page (is_home()), only on its first page, and only when ignore_sticky_posts is false. Sticky posts that would not be on the first page anyway are fetched and prepended, so that page can show more than "Number of recent posts shown on archive pages" posts. Category archives, search and custom queries ignore stickiness unless they ask for it. In block themes, the Query Loop block has a Sticky posts setting (include, exclude or only), which is the way to build a "featured news" strip. Classic themes style them through the sticky class added by post_class().

Excerpts

An excerpt is a short summary used in archives, search results, feeds (when Settings > Reading is set to show an excerpt), social previews by SEO plugins, and the Post Excerpt block. It is either written by hand in the Excerpt panel (post_excerpt column) or generated: get_the_excerpt() falls back to the first 55 words of the content with blocks, shortcodes and tags removed, followed by [&hellip;].

ID=$(wp post create --post_title='Long read' --post_status=publish --porcelain \
  --post_content="$(seq 1 80 | sed 's/^/word/' | tr '\n' ' ')")
wp eval "echo get_the_excerpt( $ID ), PHP_EOL;"     # word1 ... word55 [&hellip;]
wp eval "add_filter( 'excerpt_length', fn() => 10 ); add_filter( 'excerpt_more', fn() => ' ...' );
  echo get_the_excerpt( $ID ), PHP_EOL;"            # word1 ... word10 ...

The two filters change only generated excerpts; a hand-written excerpt is returned as is. Block themes usually set the length on the Post Excerpt block instead (excerptLength, and a "more" text). Pages have no excerpt field until a plugin adds excerpt support, as the editorial plugin below does. Excerpts are not the same as the More block (<!--more-->), which cuts the full content on index pages of classic themes and has no effect on single views.

A featured image (historically "post thumbnail") is an attachment whose ID is stored in the _thumbnail_id meta of the post. Themes display it in templates (the Post Featured Image block, or the_post_thumbnail()), and it is the default image in social previews and the REST API’s featured_media. Block themes get post-thumbnails support automatically since 5.9; classic themes must declare it. Custom post types also need thumbnail in supports.

wp media import /tmp/lumen-cover.png --post_id=10 --title='Lumen Press welcome cover' \
  --alt='Dark blue placeholder cover' --featured_image --porcelain
wp post meta get 10 _thumbnail_id
wp eval 'echo get_the_post_thumbnail( 10, "medium" ), PHP_EOL;'

The generated <img> already has srcset, sizes="auto" (6.7), decoding="async" and loading="lazy". The alt text belongs to the attachment, not to the post, so the same cover has the same alt text everywhere it is used; write it in the Media Library (see Media Library). Deleting the image from the Media Library leaves a dangling _thumbnail_id, which themes simply skip.

Post formats

Post formats (3.1+) are a fixed vocabulary a theme can use to style posts differently: aside, gallery, link, image, quote, status, video, audio and chat, plus standard meaning none. They are stored as terms of the hidden post_format taxonomy (post-format-quote), and the selector appears in the editor only when the theme declares support. Twenty Twenty-Five declares all nine; the block-theme Query Loop can filter by format.

wp post term set 10 post_format post-format-quote
wp eval 'var_dump( get_post_format( 10 ) );'

Formats are a presentation hint, not a content model: they cannot be added to, a theme switch can make them invisible, and a "quote" post is still a post with blocks. For real structured content such as a book with an ISBN, a custom post type with metadata is the right tool. Lumen Press uses only aside, quote and link in its classic theme (see the end of the next section).

Page attributes and templates

Hierarchical post types have page attributes: post_parent (the Parent field) and menu_order (the Order field, an integer used by page lists and wp_list_pages(), not by Navigation blocks). The parent builds the URL path. Deleting a parent permanently moves its children up to the grandparent; trashing it leaves them where they are.

ABOUT=$(wp post create --post_type=page --post_title='About' --post_status=publish --menu_order=10 --porcelain)
wp post create --post_type=page --post_title='Submissions' --post_parent=$ABOUT --menu_order=20 \
  --post_status=publish --porcelain
wp post create --post_type=page --post_title='Contact' --post_status=publish --menu_order=30 --porcelain
wp rewrite structure '/%year%/%monthnum%/%postname%/'
wp post list --post_type=page --orderby=menu_order --order=ASC --fields=ID,post_title,post_parent,menu_order
wp post list --post_type=page --post_parent="$ABOUT" --field=url     # http://lumenpress.test/about/submissions/

With plain permalinks the same page is /?page_id=<id>; see Rewrite API and Permalinks.

A template chosen for a single post or page is stored in the _wp_page_template meta (default or absent means the template hierarchy decides). The list of choices comes from the active theme, so a theme switch can leave a value that no longer exists; WordPress then falls back to the hierarchy, and WP-CLI refuses an unknown value with "Invalid page template".

Custom templates in a block theme

The lumen block theme declares its custom templates in theme.json (customTemplates; postTypes defaults to page when omitted) and ships one HTML file per template in templates/:

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"customTemplates": [
		{
			"name": "page-wide",
			"title": "Wide page (no sidebar)",
			"postTypes": [ "page" ]
		},
		{
			"name": "single-feature",
			"title": "Feature article",
			"postTypes": [ "post", "lp_book" ]
		}
	]
}
<!-- templates/page-wide.html -->
<!-- wp:group {"tagName":"main","align":"full","layout":{"type":"constrained","contentSize":"1100px"}} -->
<main class="wp-block-group alignfull">
	<!-- wp:post-title {"level":1} /-->
	<!-- wp:post-featured-image {"align":"wide"} /-->
	<!-- wp:post-content {"layout":{"type":"constrained","contentSize":"1100px"}} /-->
</main>
<!-- /wp:group -->

After wp theme activate lumen, wp_get_theme()->get_page_templates( null, 'page' ) returns page-wide, and wp post update 13 --page_template=page-wide assigns it.

Users who can edit theme options can also create templates from the Template panel ("Add template"); those are stored as wp_template posts in the database rather than files (see Templates and Template Parts).

Page templates in a classic theme

The classic lumen-classic theme declares a template with a Template Name: header. Template Post Type: (4.7+) makes it available to posts and custom post types too; without it, it is offered only for pages.

<?php
/**
 * Template Name: Full width
 * Template Post Type: page, post, lp_book
 */

get_header();

while ( have_posts() ) :
	the_post();
	?>
	<article id="post-<?php the_ID(); ?>" <?php post_class( 'lumen-full-width' ); ?>>
		<?php the_title( '<h1 class="entry-title">', '</h1>' ); ?>
		<?php if ( has_post_thumbnail() ) : ?>
			<?php the_post_thumbnail( 'large' ); ?>
		<?php endif; ?>
		<?php the_content(); ?>
	</article>
	<?php
endwhile;

get_footer();

Saved as page-templates/full-width.php, it is stored in _wp_page_template with its path (page-templates/full-width.php). Do not name a page template page-<slug>.php or home.php: those names are part of the template hierarchy and are picked automatically. The same theme’s functions.php calls add_theme_support( 'post-thumbnails' ) and add_theme_support( 'post-formats', array( 'aside', 'quote', 'link' ) ) on after_setup_theme; see functions.php, Theme Supports and Assets.

Static front page and posts page

By default the home page lists the latest posts. Settings > Reading > Your homepage displays switches to A static page, with two drop-downs: Homepage and Posts page. Three options hold the choice:

Option Values

show_on_front

posts (latest posts, the default) or page

page_on_front

ID of the page shown at / (0 means none)

page_for_posts

ID of the page whose URL lists the posts (0 means none)

Lumen Press shows its Home page at / and the news blog at /news/:

HOME_ID=$(wp post create --post_type=page --post_title='Home' --post_status=publish --porcelain)
NEWS_ID=$(wp post create --post_type=page --post_title='News' --post_status=publish --porcelain)
wp option update show_on_front page
wp option update page_on_front "$HOME_ID"
wp option update page_for_posts "$NEWS_ID"
wp eval "echo get_permalink( $HOME_ID ), PHP_EOL, get_permalink( $NEWS_ID ), PHP_EOL;"

The Home page is then served at http://lumenpress.test/ and the posts at http://lumenpress.test/news/. How the two pages are rendered surprises many site owners:

  • The posts page’s own content and template are ignored. Its URL is handled as the blog index (is_home()), so the theme’s home.html/home.php (or index) template renders the post list; the News page’s blocks, template and password are not used. The Reading screen documentation states the same.

  • The front page uses front-page.html/front-page.php if the theme has one, whatever show_on_front says; otherwise the page’s own template. A classic page template intended for the front page must not be called home.php, because that file name is reserved for the blog index.

  • The Homepage and Posts page must be different pages. Leaving Posts page empty is valid: the posts are then reachable only through archives, categories and feeds.

  • Number of recent posts shown on archive pages (posts_per_page, default 10; "Blog pages show at most" in older versions) controls the index, archives and the default query of the Query Loop with "inherit"; Number of recent items shown in syndication feeds (posts_per_rss) and For each post in a feed, include (full text or excerpt, rss_use_excerpt) control feeds. The other Reading setting, Search engine visibility, is covered in Settings Screens.

In the Site Editor of a block theme the same choice is also available from the Pages list ("Set as homepage" and "Set as posts page" actions).

The trash

Deleting a post, page, attachment or comment in the admin moves it to the trash. wp_trash_post() sets the status to trash and stores what is needed to restore it in meta: _wp_trash_meta_status (the previous status), _wp_trash_meta_time (a Unix timestamp) and _wp_desired_post_slug. The slug gets a __trashed suffix so that a new post can reuse it.

ID=$(wp post create --post_title='Spring catalogue announced' --post_status=draft --porcelain)
wp post delete "$ID"                     # moves to the trash
wp post meta list "$ID" --fields=meta_key,meta_value
wp post list --post_status=trash --fields=ID,post_title,post_name
wp eval "wp_untrash_post( $ID );"        # WP-CLI 2.12 has no untrash subcommand
wp post delete "$ID" --force             # deletes permanently, with its meta, terms and revisions
Success: Trashed post 37.
meta_key               meta_value
_wp_trash_meta_status  draft
_wp_trash_meta_time    1793182844
_wp_desired_post_slug

Restoring. Since 5.6, wp_untrash_post() restores a post as a draft, not with its previous status, so that a restored post is not republished by accident. The wp_untrash_post_status filter changes that, and core ships a ready-made callback, wp_untrash_post_set_previous_status(), used in the plugin below. Attachments are the exception: they always go back to inherit.

Emptying. wp_scheduled_delete() runs daily from WP-Cron and permanently deletes posts and comments whose _wp_trash_meta_time is older than EMPTY_TRASH_DAYS (default 30). The event is scheduled the first time someone loads the admin, so a site managed only by WP-CLI has no such event until then. Empty Trash on the Trash view, and Delete Permanently per item, delete immediately.

// wp-config.php, above the "That's all, stop editing!" line
define( 'EMPTY_TRASH_DAYS', 7 );      // 0 disables the trash: deletion becomes permanent and immediate
define( 'MEDIA_TRASH', true );        // also give media a trash (default false: media is deleted at once)
define( 'WP_POST_REVISIONS', 10 );    // true (all), false (none) or a number per post
define( 'AUTOSAVE_INTERVAL', 120 );   // seconds between autosaves, default 60

With EMPTY_TRASH_DAYS set to 0, the admin replaces "Trash" links with "Delete Permanently" and asks for no confirmation, and even wp post delete without --force deletes the post for good (WP-CLI still prints "Trashed", but the post is gone). Keep a positive value and rely on backups for anything older; see Backups and Disaster Recovery. Core defines these constants (in wp-includes/default-constants.php) only when wp-config.php has not, so they must be set there, not in a plugin. They can be written with wp config set EMPTY_TRASH_DAYS 7 --raw. The complete list of constants is in wp-config.php Reference.

Revisions and autosaves

Every save of a post whose type supports revisions (posts and pages do) copies the previous title, content and excerpt into a child post of type revision, status inherit, named <parent-id>-revision-v1. Autosaves are different: one per user per post, named <parent-id>-autosave-v1, overwritten every AUTOSAVE_INTERVAL seconds while editing and offered for recovery when the editor reopens. The editor’s revisions screen compares any two revisions and restores one; 7.0 also shows revisions as a timeline in the editor, covered in Block Editor for Authors.

WP_POST_REVISIONS limits how many are kept:

Value Effect

true or -1 (default)

Keep every revision. wp_revisions_to_keep() returns -1.

false or 0

Store no new revisions. Existing revisions are kept (saving does not trim them); delete them explicitly, for example with wp post delete $(wp post list --post_type=revision --format=ids) --force.

A positive integer

Keep that many per post; older ones are deleted on the next save.

The limit is applied at save time, per post. Six updates of a post with the default setting left seven revisions; after setting WP_POST_REVISIONS to 3, the next save trimmed them to three:

for i in 1 2 3 4 5 6; do wp post update 10 --post_content="Version $i" > /dev/null; done
wp eval 'echo count( wp_get_post_revisions( 10 ) ), " revisions, keep=", wp_revisions_to_keep( get_post( 10 ) ), PHP_EOL;'
wp config set WP_POST_REVISIONS 3 --raw
wp post update 10 --post_content='Version 7' > /dev/null
wp eval 'echo count( wp_get_post_revisions( 10 ) ), " revisions, keep=", wp_revisions_to_keep( get_post( 10 ) ), PHP_EOL;'
7 revisions, keep=-1
Success: Added the constant 'WP_POST_REVISIONS' to the 'wp-config.php' file with the raw value '3'.
3 revisions, keep=3

Two filters override the constant: wp_revisions_to_keep (all types) and wp_{$post_type}_revisions_to_keep (one type, 5.8+, which also overrides the general filter). A filter wins over the constant, which is why wp_revisions_enabled() can be true even with WP_POST_REVISIONS set to false. Revisions are full copies, so an old, much-edited site can hold many times more revision rows than posts; deleting them is safe:

wp post list --post_type=revision --fields=ID,post_name,post_parent --format=csv | head -4
wp post delete $(wp post list --post_type=revision --post_parent=10 --format=ids) --force

Revisions do not store post meta by default. Plugin developers can opt registered meta into revisions with revisions_enabled in register_post_meta() (6.4+), which lumen-catalog uses for lp_isbn; see Metadata and Custom Fields.

The Lumen Press editorial plugin

The rules above are site policy, so Lumen Press puts them in a must-use plugin (wp-content/mu-plugins/, loaded automatically and impossible to deactivate from the admin) rather than in a theme:

<?php
/**
 * Plugin Name: Lumen Editorial Rules
 * Description: Editorial defaults for Lumen Press: restore trashed posts to their previous status, keep a fixed number of revisions per post type, give pages excerpts and log status changes.
 * Version:     1.0.0
 * Requires at least: 6.9
 * Requires PHP: 7.4
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

// Since 5.6, untrashing restores a post as a draft. Restore the status it had before instead.
add_filter( 'wp_untrash_post_status', 'wp_untrash_post_set_previous_status', 10, 3 );

// Keep 20 revisions of pages, 10 of everything else (overrides WP_POST_REVISIONS).
add_filter(
	'wp_revisions_to_keep',
	static function ( $num, $post ) {
		return 'page' === $post->post_type ? 20 : 10;
	},
	10,
	2
);

// Pages have no excerpt field by default.
add_action(
	'init',
	static function () {
		add_post_type_support( 'page', 'excerpt' );
	}
);

// Record every status change of posts and pages in the debug log.
add_action(
	'transition_post_status',
	static function ( $new_status, $old_status, $post ) {
		if ( $new_status === $old_status || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
			return;
		}
		error_log( sprintf( 'Lumen: %s #%d %s -> %s', $post->post_type, $post->ID, $old_status, $new_status ) );
	},
	10,
	3
);

With the plugin in place and WP_DEBUG_LOG on, publishing a post, wp post delete and wp_untrash_post() leave it publish again, and wp-content/debug.log shows the transitions:

[28-Oct-2026 10:21:42 UTC] Lumen: post #26 new -> publish
[28-Oct-2026 10:21:42 UTC] Lumen: post #26 publish -> trash
[28-Oct-2026 10:21:43 UTC] Lumen: post #26 trash -> publish

Restoring to the previous status is a policy choice: it is convenient for an editorial team that trashes by mistake, but it republishes immediately, so sites that want a second look keep the default. debug.log must not stay publicly readable in production; see Debugging, Site Health and Troubleshooting.

Where older books differ

Older advice Current behaviour

The classic Publish box (Status, Visibility, Publish immediately, Move to Trash) is where status and visibility are set

In the block editor the same choices are in the Status and Publish rows of the Post/Page sidebar; the classic box exists only with the Classic Editor plugin or for post types without editor support.

The editor is chosen in Settings > Writing

There is no such setting in core; the block editor is the default, and the Classic Editor plugin adds the choice (see Block Editor for Authors).

Press This in Tools is a quick way to post

Press This was removed from core in 4.9 and is now a separate plugin.

"Reusable blocks" for content repeated across posts

Renamed synced patterns in 6.3 (see Patterns for Site Builders).

Common errors and pitfalls

Symptom Cause and fix

"Missed schedule" on a post that should be live

WP-Cron did not run at the scheduled time (no traffic, failed loopback, DISABLE_WP_CRON without a system cron). Publish it with check_and_publish_future_post() and set up a real cron.

A scheduled post goes live an hour early or late

The site timezone was a fixed offset, or it was changed after scheduling. Use a city timezone and reschedule.

The News page shows the post list but not the text written on it

It is the posts page; its content and template are ignored by design. Put an introduction in the theme’s home template or in a Query Loop on another page.

A restored post is a draft and vanished from the site

Default since 5.6. Publish it again, or add the wp_untrash_post_status filter.

A child page URL still shows the old parent

Permalinks are built from the parent’s slug at request time; check the parent and flush permalinks (Settings > Permalinks > Save).

More posts than "Number of recent posts shown on archive pages" on the first page

Sticky posts are prepended to the first page of the blog index.

The Post format, Excerpt or Featured image panel is missing

The theme does not support formats or thumbnails, the post type lacks the support, or the panel was turned off in the editor’s Preferences.

The database grows with thousands of revision rows

WP_POST_REVISIONS is true. Set a number and delete old revisions with WP-CLI.