Introduction and Architecture

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.

This page was written against WordPress 7.1.3 (released on 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), which requires PHP 7.4 or later and recommends PHP 8.3 or later, and was checked on 2026-10-28; WordPress 7.2 is in beta and planned for 2026-12-08. The commands were run with WP-CLI 2.12.0 on PHP 8.4 and MariaDB 11.8. The page covers what WordPress is as a product and as a code base, and what happens inside PHP between a request reaching index.php and a template printing HTML. The vocabulary introduced here (ABSPATH, wp-content, drop-ins, must-use plugins, hooks, the template loader) is used by every other page, and the examples belong to Lumen Press, the fictitious publisher of the sub-section.

What WordPress is

WordPress is an open source content management system written in PHP that stores its content in a MySQL or MariaDB database. It is two things at once:

  • an application for site owners, editors and authors: a dashboard (wp-admin), the block editor, a media library, users and roles, comments, menus and settings

  • a platform for developers: a stable set of PHP APIs (posts, taxonomies, metadata, options, users, HTTP, REST) and a hook system through which themes and plugins change almost every behaviour without editing core files

The sub-section is organised around four audiences: site owners who write and publish, administrators who run the servers, theme developers who control presentation, and plugin and block developers who add features.

WordPress.org, WordPress.com and WordPress VIP

The name "WordPress" covers several different products, and statements about one are often wrong about the others:

  • WordPress (also called WordPress.org or self-hosted WordPress) is the free, open source software, downloaded from wordpress.org and installed on any host. Any theme or plugin can be installed, and the owner has full access to files, database and configuration. This is what the sub-section documents.

  • WordPress.com is a commercial hosted service run by Automattic since 2005, built on WordPress with extra plugins and modifications; what can be installed depends on the plan.

  • WordPress VIP is Automattic’s enterprise hosting platform for large WordPress sites, with its own code review, deployment and support model.

The official explanation describes WordPress as a project "owned by no one individual or company" whose development decisions are made without specific regard to WordPress.com. WordPress.com plans and prices are outside the scope of this sub-section; where a page says "WordPress" it means the self-hosted software.

The GPL and what it means for themes and plugins

WordPress is released under the GNU General Public License, version 2 or any later version ("GPLv2 or later"). The GPL gives everyone the right to run, study, modify and redistribute the software, and requires that derivative works that are distributed carry the same licence. The project’s licence page states that it considers plugins and themes to be derivative works, so they inherit the GPL, while acknowledging a "legal grey area" about what counts as a derivative work.

In practice, selling themes and plugins is allowed (customers pay for convenience, support and updates, and receive the GPL freedoms, including redistribution, with the PHP code). Images, CSS and fonts that do not call WordPress code are often licensed separately ("split licensing"), but the WordPress.org theme and plugin directories require everything they host to be GPL-compatible. Running modified code on one’s own server is not distribution, so it does not oblige anyone to publish the changes. The Lumen Press plugin shown later declares License: GPL-2.0-or-later and a License URI in its header, as the Plugin Directory requires. This page is not legal advice.

A short history

WordPress began in 2003 as a fork of b2/cafelog, a PHP blogging tool. Its milestones explain much of the code that still exists today: why posts and pages share one table, why "the Loop" exists, why some APIs are global functions and others are classes.

Version, date What it introduced that still matters

0.7, 2003-05-27

The first release under the WordPress name, forked from b2/cafelog.

1.2 "Mingus", 2004-05-22

The plugin architecture: the hook system that every extension still uses.

1.5 "Strayhorn", 2005-02-17

Static Pages and the theme system with template files.

2.0 "Duke", 2005-12-31

A redesigned dashboard and the WYSIWYG editor; roles and capabilities.

2.9 and 3.0 "Thelonious", 2009-12 and 2010-06-17

register_post_type() appeared in 2.9; 3.0 made custom post types and taxonomies usable in the admin, added custom menus and merged WordPress MU into core as Multisite.

4.7 "Vaughan", 2016-12-06

The REST API content endpoints (/wp-json/wp/v2/), after the infrastructure landed in 4.4.

5.0 "Bebo", 2018-12-06

The block editor (the Gutenberg project) replaced the TinyMCE editor; the Classic Editor plugin kept the old one available.

5.9 "Joséphine", 2022-01-25

Block themes, the Site Editor, theme.json for global styles and the Navigation block ("full site editing").

6.0 to 6.9, 2022 to 2025-12-02

The block platform matured: patterns and synced patterns (6.3), Font Library, Interactivity API, Block Bindings and plugin dependencies (6.5), theme.json version 3 and plugin auto-update rollback (6.6), bcrypt password hashing and speculative loading (6.8), the Abilities API and Notes (6.9).

7.0 "Armstrong", 2026-05-20

PHP 7.4 minimum, the refreshed admin and Command Palette, the AI Client and the Connectors screen, client-side abilities, PHP-only blocks. Real-time collaboration was removed before release and has not shipped.

7.1 "Mary Lou", 2026-08-19

The always-iframed post editor, the media editor, responsive (@mobile, @tablet) styles, Abilities API filters.

X.Y is the major release (7.0 simply follows 6.9) and the third number is a maintenance release; numbering and cadence are covered in Versions and Release Cycle.

Core directories and files

A WordPress install is a directory, called ABSPATH in the code, with three subdirectories and a handful of PHP files. The split between them is the most important operational fact about WordPress: core updates replace wp-admin, wp-includes and the root files, and leave wp-content alone.

Path Contents and rules

index.php

The front controller for the public site and for the REST API (/wp-json/ is rewritten to it). Two lines of code.

wp-config.php

The only core-level file that belongs to the site: database credentials, salts, the table prefix and constants such as WP_ENVIRONMENT_TYPE or WP_DEBUG. It is not in the download (wp-config-sample.php is) and updates never touch it. It may also live one directory above ABSPATH, which wp-load.php detects on its own.

wp-load.php, wp-blog-header.php, wp-settings.php

The bootstrap chain described below.

Other root .php files

Further entry points: wp-login.php (login, password reset), wp-signup.php and wp-activate.php (Multisite sign-up), wp-cron.php (scheduled events), xmlrpc.php, wp-trackback.php, wp-comments-post.php, wp-mail.php.

wp-admin/

The dashboard: every admin screen, admin-ajax.php, the installer, the upgrader and the list tables. Files in wp-admin/includes/ are loaded only when an admin feature needs them.

wp-includes/

The core library: WP, WP_Query, WP_Rewrite, wpdb, the REST server, the Plugin API, the block parser and renderer, the template loader, the core blocks and the bundled JavaScript packages. Nothing in it may be edited.

wp-content/

Everything that belongs to the site: themes/, plugins/, mu-plugins/, uploads/, languages/, upgrade/, cache directories created by plugins, and drop-in files placed directly in it. Its location can be changed with WP_CONTENT_DIR and WP_CONTENT_URL.

A fresh 7.1.3 download contains three default themes (Twenty Twenty-Five, active after installation, plus Twenty Twenty-Four and Twenty Twenty-Three), and two plugins, Akismet and Hello Dolly. Editing any core file is never the answer: the change disappears at the next update, which may be automatic. Every customization belongs in a theme, a plugin, a must-use plugin, a drop-in or wp-config.php.

Must-use plugins

A must-use plugin is a PHP file placed directly in wp-content/mu-plugins/ (or in the directory set by WPMU_PLUGIN_DIR and WPMU_PLUGIN_URL). The official handbook lists their properties. They are always active, and the only way to disable one is to remove the file. They load before regular plugins, in alphabetical order of file name, so their hooks can affect regular plugins. Only files directly in the directory are loaded, so a plugin in a subdirectory needs a small loader file that requires it. Activation, deactivation and uninstall hooks never run for them, and they do not appear in update notifications: updating them is the site owner’s job.

They are the right place for site-wide policy that must not be switched off from the dashboard by mistake: update filters, security headers, environment-specific behaviour, or the request trace used later on this page. They appear under Plugins > Must-Use in the dashboard and in wp plugin list --status=must-use.

Drop-ins

A drop-in is a file with a reserved name placed directly in wp-content/ that replaces a piece of core behaviour. Core looks for each name at a specific point of the bootstrap, so a drop-in runs earlier than any plugin. The list, taken from _get_dropins() in 7.1.3, is:

File Purpose When it is loaded

advanced-cache.php

Full-page cache served before most of WordPress loads

Only when WP_CACHE is true

object-cache.php

Persistent object cache backend (Redis, Memcached, APCu)

Always, if present

db.php

Replacement database class

Always, if present

db-error.php

Custom "Error establishing a database connection" page

On a database connection error

maintenance.php

Custom maintenance page

While a .maintenance file exists in ABSPATH

install.php

Custom installation routine

During installation

php-error.php

Custom page shown after a fatal error

On a fatal error

fatal-error-handler.php

Replacement for the fatal error handler (recovery mode)

On a fatal error

sunrise.php (Multisite)

Code that runs before the network and site are resolved

Only when SUNRISE is defined

blog-deleted.php, blog-inactive.php, blog-suspended.php (Multisite)

Messages for deleted, inactive and archived or spam sites

When such a site is requested

Drop-ins are normally installed by a plugin (a caching plugin writes advanced-cache.php, a Redis plugin copies object-cache.php), and the Plugins screen lists them under Drop-ins. The WP_CACHE constant only tells core to load advanced-cache.php; it caches nothing by itself.

The maintenance drop-in is the simplest one to try. Lumen Press replaces the default "Briefly unavailable for scheduled maintenance" message with its own page, which also sends a Retry-After header:

wp-content/maintenance.php
<?php
// Drop-in: shown instead of the default message while a .maintenance file exists in ABSPATH.
http_response_code( 503 );
header( 'Content-Type: text/html; charset=utf-8' );
header( 'Retry-After: 600' );
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Lumen Press: back soon</title></head>
<body>
	<h1>Lumen Press is being updated</h1>
	<p>The catalogue will be back in a few minutes.</p>
</body>
</html>

Core creates the .maintenance file during updates, and wp maintenance-mode activate creates it on demand; with the drop-in above, every request then gets the 503 status, the Retry-After: 600 header and this page.

The drop-in runs from wp_maintenance(), very early in wp-settings.php, before the database connection exists, so it cannot call WordPress functions such as get_option(). A .maintenance file left behind by an interrupted update keeps the site in maintenance mode; core ignores it after ten minutes, and deleting it ends the mode immediately.

The request lifecycle

WordPress follows PHP’s request-per-process model: every HTTP request starts from nothing, loads the configuration, core, plugins and theme, does its work and exits. No application state survives between requests except what is stored in the database, the object cache or files. The Laravel introduction explains the model in general; this section follows a WordPress front-end request for a Lumen Press book page, /books/the-quiet-lighthouse/.

sequenceDiagram autonumber participant B as Browser participant S as Web server and PHP participant I as index.php participant H as wp-blog-header.php participant L as wp-load.php participant C as wp-config.php participant W as wp-settings.php participant X as Plugins and theme participant Q as WP main() participant T as template-loader.php B->>S: GET /books/the-quiet-lighthouse/ S->>I: rewrite to index.php I->>H: define WP_USE_THEMES, require H->>L: require_once L->>C: find and require wp-config.php C->>W: require_once ABSPATH wp-settings.php W->>W: constants, error handler, maintenance check, advanced-cache.php W->>W: wpdb (db.php), object cache (object-cache.php) W->>X: mu-plugins, then active plugins W-->>X: muplugins_loaded, plugins_loaded W->>X: setup_theme, child then parent functions.php W-->>X: after_setup_theme, init, wp_loaded W-->>H: bootstrap finished H->>Q: wp() Q->>Q: parse_request, query_posts, handle_404 Q-->>X: parse_request, send_headers, wp H->>T: require template-loader.php T-->>X: template_redirect T->>T: pick template, filter template_include T->>B: include template, HTML response

From the web server to wp-settings.php

The web server sends every request that does not match a real file to index.php (the .htaccess rules on Apache, a try_files line on nginx; see Web Servers: Apache and nginx). index.php only defines WP_USE_THEMES as true and requires wp-blog-header.php, which is short enough to read in full; this is its code in 7.1.3 without the file comment and blank lines:

wp-blog-header.php
<?php
if ( ! isset( $wp_did_header ) ) {
	$wp_did_header = true;
	// Load the WordPress library.
	require_once __DIR__ . '/wp-load.php';
	// Set up the WordPress query.
	wp();
	// Load the theme template.
	require_once ABSPATH . WPINC . '/template-loader.php';
}

The three lines of wp-blog-header.php are the whole lifecycle: load WordPress, run the main query, load a template. WP_USE_THEMES is what makes the template loader pick a theme template; a script that loads WordPress without it gets the API but no theme output.

wp-load.php defines ABSPATH as its own directory, sets a conservative error_reporting() level and looks for wp-config.php, first in ABSPATH and then one directory up, as long as that directory is not another WordPress install. If there is no configuration file at all, it redirects to wp-admin/setup-config.php, the first screen of the installer. wp-config.php defines the database constants, salts, $table_prefix and any other constants, and ends by requiring wp-settings.php. The last lines must stay as they are:

wp-config.php (end of the file)
<?php
// ...database settings, salts, $table_prefix and other constants above...

/* That's all, stop editing! Happy publishing. */

/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}

/** Sets up WordPress vars and included files. */
require_once ABSPATH . 'wp-settings.php';

Constants must be defined above the require_once line: anything defined after it is too late, because wp-settings.php has already applied its defaults. When wp-config.php is moved one level up, ABSPATH must not be changed; wp-load.php finds the file without help. One older book suggests changing ABSPATH in that situation, which breaks the site. Every constant is described in wp-config.php Reference.

Inside wp-settings.php

wp-settings.php is the real bootstrap. In 7.1.3 it runs these steps in this order; the order explains when a plugin or a theme can use which API.

  1. Loads version.php, load.php and the compatibility files, and checks the PHP version and extensions (wp_check_php_mysql_versions()).

  2. Defines the initial constants (wp_initial_constants(): WP_CONTENT_DIR, WP_DEBUG, WP_CACHE, memory limits and so on) and registers the fatal error handler (wp_register_fatal_error_handler(), added in 5.2 for recovery mode).

  3. Calls wp_maintenance(), which stops here with the maintenance page if a recent .maintenance file exists.

  4. Calls wp_debug_mode() to apply WP_DEBUG, WP_DEBUG_LOG and WP_DEBUG_DISPLAY.

  5. Includes advanced-cache.php if WP_CACHE is true and the file exists. A page cache can serve a stored page from here and exit, before the database is even contacted.

  6. Loads the core library files, then require_wp_db() creates the global $wpdb (or loads the db.php drop-in) and wp_start_object_cache() loads object-cache.php or the default non-persistent cache.

  7. Loads default-filters.php, the file where core attaches its own callbacks to its own hooks. On Multisite it then resolves the network and the site (ms-settings.php, which runs sunrise.php).

  8. Stops here if SHORTINIT is true. Scripts that only need $wpdb and the basic functions define it before loading wp-load.php.

  9. Loads the rest of the core library: users, posts, taxonomies, rewrite, REST, blocks, script loader and more.

  10. Loads every must-use plugin (firing mu_plugin_loaded for each), then network-activated plugins on Multisite, and fires muplugins_loaded.

  11. Defines the cookie and SSL constants, registers the default post types and taxonomies, and initialises recovery mode.

  12. Loads each active plugin from the active_plugins option (firing plugin_loaded for each), then pluggable.php (functions such as wp_mail() or wp_hash_password() that a plugin may have already defined to replace them), and fires plugins_loaded.

  13. Creates the main globals: $wp_the_query and $wp_query (WP_Query), $wp_rewrite, $wp (WP), $wp_widget_factory and $wp_roles, then fires setup_theme.

  14. Loads the default translations and the locale, then includes the active theme’s functions.php: the child theme’s first, then the parent’s. Fires after_setup_theme.

  15. Sets up the current user ($wp->init() in the code) and fires init, the hook where post types, taxonomies, blocks and shortcodes are registered.

  16. On Multisite, checks whether the site is archived, deleted or suspended. Fires wp_loaded: WordPress, every plugin and the theme are now fully loaded.

Consequences follow directly from that order. A regular plugin cannot hook muplugins_loaded, which has already fired when the plugin file is included, and a theme’s first useful hook in functions.php is after_setup_theme. is_user_logged_in() and current_user_can() answer reliably only from init onwards. Since 6.7, calling a translation function such as __() before init triggers a "translation loading triggered too early" notice.

wp() and the main query

Back in wp-blog-header.php, wp() calls $wp->main(), the method that turns a URL into a database query:

  1. parse_request() matches the request path against the rewrite rules stored in the rewrite_rules option and turns the match into query variables (lp_book=the-quiet-lighthouse for the book page, to which it adds post_type and name). The request filter can change them, and the parse_request action fires at the end. REST API requests are handled here: core hooks rest_api_loaded() to parse_request, and it serves the JSON response and exits, so a REST request never reaches the template loader.

  2. query_posts() runs the main query with those variables ($wp_the_query->query()). This is the query that the Loop and the conditional tags (is_single(), is_archive(), is_404()) describe.

  3. handle_404() sets the 404 status when nothing matched.

  4. register_globals() exposes $post, $posts and the query variables as globals.

  5. send_headers() sends the HTTP headers (filter wp_headers, action send_headers).

  6. The wp action fires: the query is known, the headers are sent and no body has been printed yet.

template-loader.php

wp-includes/template-loader.php decides which file renders the response:

  1. It fires template_redirect, the last hook before output. Redirects, access checks and custom non-HTML responses belong here.

  2. A HEAD request exits here (filter exit_on_http_head), and robots.txt, the favicon, feeds and trackbacks are handled without a theme template.

  3. It walks the conditional tags in a fixed order (is_embed, is_404, is_search, is_front_page, is_home, is_privacy_policy, is_post_type_archive, is_tax, is_attachment, is_single, is_page, is_singular, is_category, is_tag, is_author, is_date, is_archive) and, for each one that is true, asks the matching get_*_template() function for a file following the template hierarchy, stopping at the first file found. If none is found, it uses index.php.

  4. It applies the template_include filter, fires wp_before_include_template and includes the file.

For a classic theme the included file is a PHP template from the theme, such as single-lp_book.php. For a block theme the hierarchy is resolved against the theme’s HTML templates (and the user’s edited copies stored in the database), and the file actually included is core’s wp-includes/template-canvas.php, which renders the chosen block template. The order in which candidate templates are tried is the subject of Template Hierarchy. When the template finishes, PHP runs the shutdown action and the process ends.

Other entry points

All entry points load the same bootstrap, so plugins and the theme’s functions.php are loaded on every one of them.

Entry point What differs from a front-end request

/wp-admin/*.php

wp-admin/admin.php loads wp-load.php and then the admin library; admin_init fires, a login is required, and admin_menu, admin_enqueue_scripts and the screen hooks follow. No main query and no theme template.

/wp-json/... (REST API)

Goes through index.php and wp(), but rest_api_loaded() dispatches the route during parse_request and exits. rest_api_init is where routes are registered.

wp-admin/admin-ajax.php

Defines DOING_AJAX, loads WordPress and fires wp_ajax_{action} or wp_ajax_nopriv_{action}. Still supported, but new code uses the REST API.

wp-cron.php

Defines DOING_CRON and runs due scheduled events. See WP-Cron and Scheduled Tasks.

wp-login.php, xmlrpc.php

Login, logout and password reset (application passwords never work here); the legacy XML-RPC API, best disabled.

WP-CLI

Loads wp-config.php and wp-settings.php from the command line without a web server. The bootstrap hooks fire, but wp() and the template loader do not run.

The last row can be checked with wp eval and did_action(): in a WP-CLI process init and wp_loaded report 1, while parse_request, wp and template_redirect report 0.

Tracing a real request

The order above can be observed on a running site with a must-use plugin that records the time at which each hook fires and writes the result to the debug log at shutdown. It is a development tool, so it does nothing unless the environment type is local.

wp-content/mu-plugins/lumen-request-trace.php
<?php
/**
 * Plugin Name: Lumen Request Trace
 * Description: Logs the order of the main bootstrap hooks for one request (local environments only).
 * Version:     1.0.0
 * Author:      Lumen Press
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

if ( 'local' !== wp_get_environment_type() ) {
	return;
}

$lumen_trace_hooks = array(
	'muplugins_loaded', 'plugins_loaded', 'setup_theme', 'after_setup_theme', 'init', 'wp_loaded',
	'parse_request', 'send_headers', 'wp', 'template_redirect', 'wp_before_include_template',
	'wp_head', 'wp_footer', 'shutdown',
);

$GLOBALS['lumen_trace'] = array();

foreach ( $lumen_trace_hooks as $lumen_hook ) {
	add_action(
		$lumen_hook,
		static function () use ( $lumen_hook ) {
			$GLOBALS['lumen_trace'][ $lumen_hook ] = microtime( true );
		},
		PHP_INT_MIN
	);
}

add_filter(
	'template_include',
	static function ( $template ) {
		$GLOBALS['lumen_trace'][ 'template_include: ' . basename( (string) $template ) ] = microtime( true );
		return $template;
	},
	PHP_INT_MAX
);

add_action(
	'shutdown',
	static function () {
		$start = (float) $_SERVER['REQUEST_TIME_FLOAT'];
		$lines = array();
		foreach ( $GLOBALS['lumen_trace'] as $step => $time ) {
			$lines[] = sprintf( '%7.1f ms  %s', ( $time - $start ) * 1000, $step );
		}
		$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '(cli)';
		error_log( 'Lumen trace for ' . $uri . PHP_EOL . implode( PHP_EOL, $lines ) );
	},
	PHP_INT_MAX
);

The plugin registers its callbacks with priority PHP_INT_MIN, so they run before any other callback on the same hook, and the template_include filter with PHP_INT_MAX, so it sees the template that every other filter agreed on. With WP_ENVIRONMENT_TYPE set to local and WP_DEBUG_LOG set to true, a second (warm) request for a book page writes this to wp-content/debug.log. The theme is Twenty Twenty-Five, a block theme, which is why the included file is template-canvas.php:

[28-Oct-2026 10:05:18 UTC] Lumen trace for /books/the-quiet-lighthouse/
   70.8 ms  muplugins_loaded
   73.2 ms  plugins_loaded
   76.6 ms  setup_theme
   79.1 ms  after_setup_theme
   79.6 ms  init
  104.4 ms  wp_loaded
  104.7 ms  parse_request
  105.8 ms  send_headers
  105.8 ms  wp
  106.1 ms  template_redirect
  138.0 ms  template_include: template-canvas.php
  138.2 ms  wp_before_include_template
  178.5 ms  wp_head
  199.4 ms  wp_footer
  201.6 ms  shutdown

The absolute times come from PHP’s built-in development server and mean little; the order is the point. Keep such a file out of deployments: the debug log can be publicly readable at its default location.

The hook system as the extension model

Hooks are the reason WordPress can be customised without editing core. Core, and any plugin, calls do_action() or apply_filters() at named points; any code can register a callback for those names with add_action() or add_filter(). There are two kinds:

  • Actions run code at a point of the lifecycle: register a post type on init, add a header on send_headers, enqueue a stylesheet on wp_enqueue_scripts. Their return value is ignored.

  • Filters receive a value, may change it and must return it: the post title (the_title), the content (the_content), the chosen template (template_include). The Plugin Handbook adds that filters should work in isolation and never have side effects such as printing output or changing globals.

Every callback has a priority (default 10; lower runs first) and declares how many arguments it accepts. All registrations live in one global registry, $wp_filter, keyed by hook name, so plugins and themes cooperate without knowing each other.

The first version of the Lumen Press plugin uses one hook of each kind: an action registers the lp_book post type on init, and a filter marks scheduled books as forthcoming wherever their title is printed.

wp-content/plugins/lumen-catalog/lumen-catalog.php
<?php
/**
 * Plugin Name:       Lumen Catalog
 * Description:       Book catalogue for Lumen Press.
 * Version:           0.1.0
 * Requires at least: 6.9
 * Requires PHP:      7.4
 * Author:            Lumen Press
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       lumen-catalog
 */

defined( 'ABSPATH' ) || exit;

// Action: run code at a point of the lifecycle.
add_action( 'init', 'lumen_catalog_register_book_type' );

function lumen_catalog_register_book_type() {
	register_post_type(
		'lp_book',
		array(
			'label'        => __( 'Books', 'lumen-catalog' ),
			'public'       => true,
			'has_archive'  => true,
			'rewrite'      => array( 'slug' => 'books' ),
			'show_in_rest' => true, // Block editor and /wp/v2/lp_book.
			'supports'     => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
		)
	);
}

// Filter: receive a value, return it (changed or not).
add_filter( 'the_title', 'lumen_catalog_mark_forthcoming', 10, 2 );

function lumen_catalog_mark_forthcoming( $title, $post_id = 0 ) {
	if ( 'lp_book' === get_post_type( $post_id ) && 'future' === get_post_status( $post_id ) ) {
		/* translators: %s: book title. */
		$title = sprintf( __( '%s (forthcoming)', 'lumen-catalog' ), $title );
	}
	return $title;
}

Activate it, flush the permalinks once and create two books, one published and one scheduled:

wp plugin activate lumen-catalog
wp rewrite structure '/%postname%/'
wp post create --post_type=lp_book --post_title="The Quiet Lighthouse" --post_status=publish --porcelain
wp post create --post_type=lp_book --post_title="Winter Orchard" --post_status=future --post_date="2027-03-01 09:00:00" --porcelain
wp eval 'foreach ( get_posts( array( "post_type" => "lp_book", "post_status" => "any" ) ) as $p ) { echo get_the_title( $p ), PHP_EOL; }'
Plugin 'lumen-catalog' activated.
Success: Activated 1 of 1 plugins.
Success: Rewrite structure set.
Success: Rewrite rules flushed.
4
5
Winter Orchard (forthcoming)
The Quiet Lighthouse

The flags in register_post_type() matter beyond this page: 'show_in_rest' set to true is what gives the post type a REST endpoint and the block editor. One older book registers a custom post type without it, which leaves the type on the classic editor and invisible to the REST API. The full treatment of hooks (priorities, removing callbacks, dynamic hook names, creating hooks in a plugin) is in Hooks: Actions and Filters, and custom post types are covered in Custom Post Types and Taxonomies.

The block editor: a React application over the REST API

Since 5.0 the post editor, and since 5.9 the Site Editor, are JavaScript applications that run in the browser. They are built from the @wordpress/* npm packages developed in the Gutenberg repository and bundled with core: React components (@wordpress/components), data stores (@wordpress/data, with core/block-editor for the editor state and core from @wordpress/core-data for posts, users and settings), and @wordpress/api-fetch for HTTP calls. The PHP side renders an almost empty admin page and the scripts; everything the editor reads or saves (the post, its revisions, the media library, the templates of a block theme, the global styles) goes through the REST API. The REST API Handbook calls the API "the foundation of the WordPress Block Editor".

Content is stored as HTML in post_content, with block names and attributes kept in HTML comments (<!-- wp:paragraph --> …​ <!-- /wp:paragraph -->), so the content still renders without the editor.

Because the editor talks to the REST API, a custom post type without show_in_rest cannot use it, and a field that a plugin does not expose in REST is invisible to it. The same endpoints serve anyone else within the requesting user’s permissions: on the Lumen Press site, an anonymous GET /wp-json/wp/v2/lp_book lists the published books, while the ?context=edit form that the editor uses answers 401 with the code rest_forbidden_context. In the editor each request carries the login cookie plus a wp_rest nonce; a cookie-authenticated request without the nonce is treated as anonymous. Since 7.1 the post editor always runs inside an iframe, so block code that touches the admin page’s window.document or depends on global admin CSS breaks, and front-end interactivity on server-rendered pages uses the Interactivity API rather than React. These topics continue in REST API: Using It, Block Development Environment and Interactivity API.

Core architecture of a self-hosted WordPress site: clients, PHP entry points, the bootstrap and wp-includes, wp-content extensions and the data stores

Running the examples

The examples on this page need a running site. This Compose file uses the official images, pinned to WordPress 7.1.3, WP-CLI 2.12.0 and MariaDB 11.8, and mounts the Lumen Press must-use plugin, plugin and drop-in from the project directory. Database passwords come from an .env file next to it; the values shown are placeholders.

compose.yaml
x-wp-env: &wp-env
  WORDPRESS_DB_HOST: db
  WORDPRESS_DB_USER: wordpress
  WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
  WORDPRESS_DB_NAME: wordpress
x-wp-volumes: &wp-volumes
  - wp:/var/www/html
  - ./mu-plugins:/var/www/html/wp-content/mu-plugins
  - ./lumen-catalog:/var/www/html/wp-content/plugins/lumen-catalog
  - ./maintenance.php:/var/www/html/wp-content/maintenance.php

services:
  db:
    image: mariadb:11.8
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes: [db:/var/lib/mysql]
  wordpress:
    image: wordpress:7.1.3-php8.4-apache
    depends_on: [db]
    ports: ["8080:80"]
    environment:
      <<: *wp-env
      WORDPRESS_DEBUG: "1"
      WORDPRESS_CONFIG_EXTRA: |
        define( 'WP_ENVIRONMENT_TYPE', 'local' );
        define( 'WP_DEBUG_LOG', true );
        define( 'WP_DEBUG_DISPLAY', false );
    volumes: *wp-volumes
  cli:
    image: wordpress:cli-2.12.0-php8.4
    profiles: [cli]
    user: "33:33"
    depends_on: [db, wordpress]
    environment: *wp-env
    volumes: *wp-volumes

volumes:
  db:
  wp:
printf 'DB_PASSWORD=<your-db-password>\nDB_ROOT_PASSWORD=<your-root-password>\n' > .env
docker compose up -d
docker compose run --rm cli wp core install --url=http://localhost:8080 --title="Lumen Press" \
  --admin_user=admin --admin_email=admin@lumenpress.test --prompt=admin_password
docker compose run --rm cli wp plugin activate lumen-catalog
docker compose run --rm cli wp plugin list --fields=name,status
name	status
akismet	inactive
hello	inactive
lumen-catalog	active
lumen-request-trace	must-use
maintenance.php	dropin

The list shows the three kinds of extension side by side: regular plugins, a must-use plugin and a drop-in. docker compose down -v removes the containers and volumes. Local environments are compared in Local Development Environments.

Where older books and tutorials are outdated

The architecture has changed less than the features, but several statements in older books no longer match core:

Older claim Current behaviour

WordPress has built-in caching and CSS/JavaScript minification.

Core has no page cache and does not concatenate or minify front-end assets. The WP_CACHE constant only loads an advanced-cache.php drop-in that a caching plugin or the host provides, and a persistent object cache needs an object-cache.php drop-in. Core’s own non-persistent object cache lasts for one request. The only concatenation in core is for scripts and styles on admin screens (load-scripts.php, load-styles.php). What core does provide is covered in Performance and Caching.

Core includes two-factor authentication.

It does not. Core has passwords (hashed with bcrypt since 6.8), a strength meter and application passwords for the APIs. Two-factor login needs a plugin such as the canonical Two Factor plugin, single sign-on, or passkeys through a plugin. See Security Hardening.

Version X.Y.Z means major.minor.patch.

X.Y is the major release and Z is the maintenance release; 6.9 to 7.0 is an ordinary major step.

Full site editing arrived in 5.4 to 5.6.

Block themes and the Site Editor shipped in 5.9 (January 2022); earlier versions only had experiments in the Gutenberg plugin.

Change ABSPATH when wp-config.php is moved up one directory.

wp-load.php finds the file one level up on its own; ABSPATH must not be changed.

All corrections collected across the sub-section are listed in Where Older Books and Tutorials Are Outdated.

Common pitfalls

  • Editing core files. Changes in wp-admin, wp-includes or the root PHP files are lost at the next update, and automatic minor updates make that happen without warning. Use a plugin, a must-use plugin or a drop-in.

  • Registering things on the wrong hook. Post types, taxonomies, blocks and shortcodes are registered on init, REST routes on rest_api_init, theme features on after_setup_theme. Registering earlier fails because the APIs or the current user are not ready; registering on wp or later is too late for the main query.

  • Doing heavy work in functions.php or the plugin file body. That code runs on every request, including REST, AJAX, cron and WP-CLI. Attach it to a hook and check what kind of request is running (wp_doing_ajax(), wp_doing_cron(), wp_is_serving_rest_request()).

  • Leaving a development drop-in or must-use plugin in production. A forgotten object-cache.php pointing to a missing Redis server, or a tracing plugin, affects every request; check wp plugin list --status=dropin and --status=must-use during deployments.