How WordPress Works

PHP plus MySQL, assembled per request: the bootstrap chain, the main query, and where core, plugins and themes each fit.

Syntaxindex.php -> wp-blog-header.php -> wp-load.php -> wp-config.php -> wp-settings.php -> wp() -> template-loader.php

WordPress is a PHP application in front of a MySQL or MariaDB database. There are no HTML files to serve — every uncached front-end request runs PHP, queries the database, and prints the page.

The request

The web server rewrites the URL to the root index.php, which defines WP_USE_THEMES and requires wp-blog-header.php. That file is nine lines and does three things: it requires wp-load.php (which loads wp-config.php, which in turn requires wp-settings.php — where core files, must-use plugins, active plugins and the theme's functions.php are all pulled in), calls wp() to turn the URL into query variables and run the main query, then requires wp-includes/template-loader.php to choose a template file and render it.

Core, plugins, themes

Core is wp-admin and wp-includes. It is replaced wholesale on every update, so you never edit it. A plugin adds behaviour — post types, endpoints, business logic — and survives a theme change. A theme owns presentation: templates, styles and functions.php. Plugins and themes extend core the same way, by registering callbacks on hooks, and neither one patches core code.

What trips people up

Nothing is compiled and nothing is cached by default. A fatal error in a plugin loaded early in wp-settings.php takes down /wp-admin along with the front end. And because content lives in the database rather than in files, a working site is code plus a database — deploying only files gives you an empty install.

Example

Example · php
<?php
/**
 * single.php — the template the hierarchy picks for one post.
 *
 * By the time this file is included by wp-includes/template-loader.php,
 * wp() has already parsed the URL and run the main query, so the posts are
 * sitting in the global $wp_query. Templates render; they do not fetch.
 */

get_header(); // Includes header.php from the active theme (child theme first).

// The Loop: iterate the posts the main query already fetched.
while ( have_posts() ) :
	the_post(); // Sets up the global $post so the template tags below work.
	?>
	<article id="post-<?php the_ID(); ?>">
		<h1><?php the_title(); ?></h1>

		<p><?php echo esc_html( get_the_date() ); ?></p>

		<?php
		/*
		 * the_content() echoes the post body after running the 'the_content'
		 * filter. That filter is what expands shortcodes, renders blocks and
		 * turns bare URLs into embeds — which is why you never echo the raw
		 * post_content column yourself.
		 */
		the_content();
		?>
	</article>
	<?php
endwhile;

get_footer();

When to use it

  • Tracing why a callback registered in a theme runs too late to affect a plugin's output.
  • Explaining to a hosting team why the site needs PHP workers and a cache rather than more static bandwidth.
  • Deciding whether new functionality belongs in a plugin, a must-use plugin, or the theme.

More examples

Counting the queries one request runs

The fastest way to see that a page really is assembled per request, and the first thing to reach for when a page feels slow. A plain post archive should be well under 50 queries; several hundred usually means a plugin is querying inside the loop.

Example · php
<?php
// 1. In wp-config.php, above the "stop editing" line:
define( 'SAVEQUERIES', true ); // Never leave this on in production.

// 2. In wp-content/mu-plugins/query-log.php:
add_action( 'shutdown', static function () {
	if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) {
		return;
	}

	global $wpdb;

	// $wpdb->queries is an array of arrays: [0] the SQL, [1] seconds taken,
	// [2] the calling stack. $wpdb->num_queries is the running count.
	$total = array_sum( wp_list_pluck( $wpdb->queries, 1 ) );

	error_log( sprintf(
		'%s: %d queries in %.3fs',
		$_SERVER['REQUEST_URI'] ?? 'cli',
		$wpdb->num_queries,
		$total
	) );
} );

Watching the bootstrap fire

Prints the order and timing of the main lifecycle hooks into the debug log. Use it when a callback is not firing: usually the hook you chose runs before the thing you depend on exists, and seeing the real order tells you which hook to move to.

Example · php
<?php
// wp-content/mu-plugins/trace.php — mu-plugins load before normal plugins,
// so this sees almost the whole sequence.

foreach ( array( 'muplugins_loaded', 'plugins_loaded', 'setup_theme', 'after_setup_theme', 'init', 'wp_loaded', 'parse_request', 'pre_get_posts', 'wp', 'template_redirect', 'shutdown' ) as $acme_hook ) {
	add_action( $acme_hook, static function () use ( $acme_hook ) {
		// WP_START_TIMESTAMP is defined at the very top of the bootstrap.
		error_log( sprintf( '%-18s +%.4fs', $acme_hook, microtime( true ) - WP_START_TIMESTAMP ) );
	}, 0 );
}

The same change, in the wrong place and the right place

The single rule that separates maintainable WordPress from the kind you inherit and dread. Anything you want to change in core output almost certainly has an action or filter next to it; hooking is the supported route and survives updates.

Example · php
<?php
// WRONG: editing wp-includes/general-template.php to add a meta tag.
// The next core update overwrites the file and the change vanishes.

// RIGHT: register a callback on the hook core already fires there.
add_action( 'wp_head', static function () {
	if ( ! is_singular() ) {
		return;
	}

	$excerpt = get_the_excerpt();
	if ( '' === $excerpt ) {
		return;
	}

	printf(
		'<meta name="description" content="%s" />' . "\n",
		esc_attr( wp_trim_words( $excerpt, 30 ) )
	);
}, 1 );

Discussion

  • Be the first to comment on this lesson.