Classic Theme Anatomy and The Loop

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.

A classic theme draws every front-end page with PHP. The template loader picks one file from the theme (the template hierarchy), and that file assembles the page from smaller files: the header, the footer and reusable template parts. In the middle sits The Loop, the few lines of PHP that walk through the posts WordPress has already queried for the request and print each one with template tags. This page is for developers who maintain or build classic themes, or who must read one before migrating it to a block theme. Every example is part of lumen-classic, the classic theme of the Lumen Press site, and was rendered on a real installation.

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, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; no change to classic templates or The Loop has been announced for it. Classic themes remain fully supported: block themes are the default since 5.9, but WordPress has not deprecated PHP templates, and the same Loop runs inside block themes too (the Query Loop block and the Post Template block wrap it).

How WordPress chooses which template file to load is described in Template Hierarchy; the kinds of theme, the style.css header and the minimal lumen-classic skeleton in Themes Overview. functions.php, theme supports and asset loading have their own page, functions.php, Theme Supports and Assets.

The files of a classic theme

A classic theme needs only style.css and index.php. Every other template file is optional: it is a more specific candidate in the template hierarchy, or a partial that templates include. The file names are fixed by WordPress, not by convention, so a misspelt name is silently ignored.

File Role

style.css

Theme header (name, version, requirements, text domain) and, usually, the main stylesheet. Required.

index.php

Last fallback of the template hierarchy; renders any request no other file matches. Required.

functions.php

Loaded on every request before the template; declares theme supports and enqueues assets. Optional.

header.php, footer.php, sidebar.php

Partials loaded by get_header(), get_footer() and get_sidebar(). Named variants such as header-shop.php are loaded by get_header( 'shop' ).

front-page.php, home.php

The site front page, and the blog posts index (the front page by default, or the "Posts page" in Settings > Reading).

singular.php, single.php, single-{post_type}.php, page.php

A single post, entry of a custom post type, or page. singular.php (4.3) is the fallback for both posts and pages.

archive.php, archive-{post_type}.php, category.php, tag.php, taxonomy-{taxonomy}.php, author.php, date.php

Lists of posts: post type archives, term archives, author and date archives.

search.php, 404.php, attachment.php

Search results, "not found", and attachment pages (off by default since 6.4).

comments.php, searchform.php

Partials loaded by comments_template() and get_search_form().

template-parts/

Partials loaded with get_template_part(). The folder name is a convention; any path relative to the theme works.

The lumen-classic theme built on this page has these files. style.css and functions.php are the ones shown in Themes Overview, unchanged: the setup function lumen_classic_setup declares title-tag, post-thumbnails and html5, and lumen_classic_enqueue_assets enqueues style.css under the handle lumen-classic-style.

wp-content/themes/lumen-classic/
├── archive-lp_book.php        the book catalogue at /books/
├── footer.php
├── functions.php
├── header.php
├── index.php                  blog index, pages, search, every other request
├── single.php                 single posts and single books
├── style.css
└── template-parts/
    ├── content.php            a post or page
    ├── content-lp_book.php    a book
    └── content-none.php       "nothing found"

The book post type lp_book, its lp_genre taxonomy and the lp_isbn meta field come from the lumen-catalog plugin (see Custom Post Types and Taxonomies); the theme only displays them. A theme that registered them itself would lose the catalogue on a theme switch.

Templates do not repeat the document head and the site header; they include them. get_header( $name, $args ) loads header-{name}.php or else header.php; get_footer() and get_sidebar() work the same way, and get_template_part( $slug, $name, $args ) loads {slug}-{name}.php or else {slug}.php, firing the get_template_part_{slug} and get_template_part actions. get_search_form() loads searchform.php (else the core form) and comments_template() loads comments.php. All of them go through locate_template(), which looks for each candidate in the child theme, then the parent theme, then wp-includes/theme-compat/.

Three details matter in practice:

  • Arguments (5.5). The last parameter of each function is an array that reaches the included file as $args, replacing the globals and set_query_var() calls older themes used.

  • Return value (5.5). get_template_part(), get_header() and the others return false when no file was found, so a template can detect a missing part. They print nothing in that case.

  • Fallback files. When a theme has no header.php, footer.php, sidebar.php or comments.php, WordPress loads the copy in wp-includes/theme-compat/ and logs a deprecation notice (deprecated since 3.0). A theme ships its own.

header.php opens the document and must call language_attributes(), wp_head(), body_class() and wp_body_open(). It ends by opening <main>, which footer.php closes:

<?php
/**
 * The document head and the site header, loaded by get_header().
 *
 * @package Lumen_Classic
 */

?><!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
	<meta charset="<?php bloginfo( 'charset' ); ?>">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<a class="skip-link screen-reader-text" href="#content"><?php esc_html_e( 'Skip to content', 'lumen-classic' ); ?></a>
<header class="site-header">
	<?php if ( is_front_page() ) : ?>
		<h1 class="site-title"><a href="<?php echo esc_url( home_url( '/' ) ); ?>" rel="home"><?php bloginfo( 'name' ); ?></a></h1>
	<?php else : ?>
		<p class="site-title"><a href="<?php echo esc_url( home_url( '/' ) ); ?>" rel="home"><?php bloginfo( 'name' ); ?></a></p>
	<?php endif; ?>
	<p class="site-description"><?php bloginfo( 'description' ); ?></p>
</header>
<main id="content" class="site-main">

There is no <title> and no stylesheet <link>: wp_head() prints both, from the title-tag support and the wp_enqueue_style() call in functions.php. The site name is an <h1> only on the front page.

<?php
/**
 * Closes the main element, prints the site footer and the footer hook.
 *
 * @package Lumen_Classic
 */

?>
</main>
<footer class="site-footer">
	<p>&copy; <?php echo esc_html( wp_date( 'Y' ) ); ?> <?php bloginfo( 'name' ); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>

The hooks every template must call

Plugins and core output most of what a page needs through a handful of template functions. A theme that leaves one out breaks features that have nothing to do with the theme, usually without an error message.

Call Where What breaks without it

wp_head()

Just before </head>

Enqueued styles and scripts (the theme’s own lumen-classic-style included), the <title> from title-tag, canonical and robots meta, SEO plugins, global styles, the admin bar CSS.

wp_body_open() (5.2)

Right after <body>

Plugins that must print first in the body (tag managers, skip links, consent banners). Required by the Theme Directory.

wp_footer()

Just before </body>

Footer scripts (most enqueued JavaScript), the admin bar itself, speculative loading rules, emoji support.

language_attributes()

On <html>

The lang and dir attributes screen readers and right-to-left languages rely on.

body_class()

On <body>

The classes (single-lp_book, postid-11, wp-singular, wp-theme-lumen-classic) that plugins and theme CSS target.

post_class()

On each post’s wrapper

Per-post classes: type-lp_book, status-publish, has-post-thumbnail, one class per term (lp_genre-literary-fiction), sticky.

body_class() gained wp-singular on singular views and wp-theme-{template} (plus wp-child-theme-{stylesheet}) in 6.8. Both functions accept extra classes, as in post_class( 'book' ) below, and have filters of the same name.

The Loop

When a template file starts, the work of finding posts is already done. WP::main() parsed the URL into query variables and ran the main query, a WP_Query object stored in the global $wp_query, and the template loader then included the theme file. The Loop is the code that walks through the posts of that query:

<?php
/**
 * The main template file: the last fallback of the template hierarchy.
 *
 * @package Lumen_Classic
 */

get_header();

if ( have_posts() ) :
	while ( have_posts() ) :
		the_post();
		get_template_part( 'template-parts/content', get_post_type() );
	endwhile;

	the_posts_pagination();
else :
	get_template_part( 'template-parts/content', 'none' );
endif;

get_footer();

index.php serves the blog index, pages (the theme has no page.php) and search results. get_post_type() picks content-page.php for a page; that file does not exist, so get_template_part() falls back to content.php. Templates use PHP’s alternative syntax (if … endif), see Control Structures.

The two functions do more than their names suggest. have_posts() and the_post() are wrappers around the same methods of $wp_query:

  • have_posts() returns true while posts remain. When it runs past the last post it fires the loop_end action and calls rewind_posts(), so a second Loop over the same query starts from the beginning. When the query found nothing it fires loop_no_results (4.9) instead.

  • the_post() advances to the next post, sets the global $post and calls setup_postdata(), which fills the globals that template tags read ($id, $authordata, $pages, $page, $more and others) and fires the the_post action. On the first call it also fires loop_start and primes the author cache for all posts at once.

Template tags such as the_title() read the current post from these globals, which is why they take no post ID inside the Loop and why they print the wrong post when the globals are left pointing elsewhere (see Secondary loops).

flowchart TD A["WP::main() parses the URL and runs the main WP_Query ($wp_query)"] --> B["Template loader includes the theme file, for example index.php"] B --> C{"have_posts()"} C -- "true: posts remain" --> D["the_post(): first call fires loop_start"] D --> E["$post = next post; setup_postdata() fills the globals and fires the_post"] E --> F["Template tags print the current post: the_title(), the_content(), post_class()"] F --> C C -- "false after the last post" --> G["loop_end fires; rewind_posts() resets the counter"] C -- "false, query found nothing" --> H["loop_no_results fires; the else branch prints content-none"] G --> I["the_posts_pagination(), get_footer()"] H --> I

Single posts and the book archive

A single-post template still uses The Loop, although it holds one post: the_post() is what sets up the globals. single.php serves both blog posts and books (the theme has no single-lp_book.php), and passes the context to the template part through $args. Its secondary loop is explained in Secondary loops.

<?php
/**
 * A single post of any type: blog posts and lp_book entries.
 *
 * @package Lumen_Classic
 */

get_header();

while ( have_posts() ) :
	the_post();
	get_template_part( 'template-parts/content', get_post_type(), array( 'context' => 'single' ) );
endwhile;

if ( is_singular( 'lp_book' ) ) :
	$lumen_genres = wp_get_post_terms( get_queried_object_id(), 'lp_genre', array( 'fields' => 'ids' ) );

	if ( $lumen_genres && ! is_wp_error( $lumen_genres ) ) :
		$lumen_related = new WP_Query(
			array(
				'post_type'           => 'lp_book',
				'posts_per_page'      => 3,
				'post__not_in'        => array( get_queried_object_id() ),
				'tax_query'           => array( array( 'taxonomy' => 'lp_genre', 'terms' => $lumen_genres ) ),
				'ignore_sticky_posts' => true,
				'no_found_rows'       => true,
			)
		);

		if ( $lumen_related->have_posts() ) :
			?>
			<section class="related-books" aria-labelledby="related-books-title">
				<h2 id="related-books-title"><?php esc_html_e( 'More in this genre', 'lumen-classic' ); ?></h2>
				<ul>
					<?php
					while ( $lumen_related->have_posts() ) :
						$lumen_related->the_post();
						?>
						<li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
					<?php endwhile; ?>
				</ul>
			</section>
			<?php
			wp_reset_postdata();
		endif;
	endif;
else :
	the_post_navigation();
endif;

get_footer();

The catalogue at /books/ is the post type archive of lp_book, so the template hierarchy picks archive-lp_book.php. post_type_archive_title() prints the plural label ("Books") without the "Archives:" prefix that the_archive_title() adds.

<?php
/**
 * The book catalogue at /books/: the lp_book post type archive.
 *
 * @package Lumen_Classic
 */

get_header();
?>
<header class="page-header">
	<h1><?php post_type_archive_title(); ?></h1>
</header>
<?php
if ( have_posts() ) :
	while ( have_posts() ) :
		the_post();
		get_template_part( 'template-parts/content', 'lp_book', array( 'context' => 'archive' ) );
	endwhile;

	the_posts_pagination( array( 'next_text' => __( 'Older books', 'lumen-classic' ) ) );
else :
	get_template_part( 'template-parts/content', 'none' );
endif;

get_footer();

Posts per page come from Settings > Reading for every main query; changing it for one archive is done with pre_get_posts (see Changing the main query), not a new query in the template.

The template parts

content.php prints a post or page. is_singular() switches between the full content with an <h1> and the excerpt with a linked <h2>:

<?php
/**
 * A post in a list or on its own page.
 *
 * @package Lumen_Classic
 */

?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
	<?php if ( is_singular() ) : ?>
		<?php the_title( '<h1 class="entry-title">', '</h1>' ); ?>
	<?php else : ?>
		<?php the_title( '<h2 class="entry-title"><a href="' . esc_url( get_permalink() ) . '">', '</a></h2>' ); ?>
	<?php endif; ?>

	<p class="entry-meta">
		<time datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>"><?php echo esc_html( get_the_date() ); ?></time>
		<?php
		/* translators: %s: author name. */
		printf( esc_html__( 'by %s', 'lumen-classic' ), esc_html( get_the_author() ) );
		?>
	</p>

	<?php if ( is_singular() ) : ?>
		<div class="entry-content"><?php the_content(); ?></div>
		<?php wp_link_pages(); ?>
	<?php else : ?>
		<div class="entry-summary"><?php the_excerpt(); ?></div>
	<?php endif; ?>
</article>

content-lp_book.php prints a book. It reads the context from $args, the ISBN from post meta and the genres with the_terms():

<?php
/**
 * A book from the catalogue. $args['context'] is 'single' or 'archive'.
 *
 * @package Lumen_Classic
 */

$lumen_context = isset( $args['context'] ) ? $args['context'] : 'archive';
$lumen_isbn    = get_post_meta( get_the_ID(), 'lp_isbn', true );
?>
<article id="book-<?php the_ID(); ?>" <?php post_class( 'book' ); ?>>
	<?php if ( 'single' === $lumen_context ) : ?>
		<?php the_title( '<h1 class="entry-title">', '</h1>' ); ?>
		<?php the_post_thumbnail( 'medium' ); ?>
	<?php else : ?>
		<h2 class="entry-title"><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
		<?php if ( has_post_thumbnail() ) : ?>
			<a href="<?php the_permalink(); ?>" aria-label="<?php the_title_attribute(); ?>" tabindex="-1">
				<?php the_post_thumbnail( 'thumbnail' ); ?>
			</a>
		<?php endif; ?>
	<?php endif; ?>

	<dl class="book-details">
		<?php if ( $lumen_isbn ) : ?>
			<dt><?php esc_html_e( 'ISBN', 'lumen-classic' ); ?></dt>
			<dd><?php echo esc_html( $lumen_isbn ); ?></dd>
		<?php endif; ?>
		<?php the_terms( get_the_ID(), 'lp_genre', '<dt>' . esc_html__( 'Genre', 'lumen-classic' ) . '</dt><dd>', ', ', '</dd>' ); ?>
	</dl>

	<?php if ( 'single' === $lumen_context ) : ?>
		<div class="entry-content"><?php the_content(); ?></div>
	<?php else : ?>
		<div class="entry-summary"><?php the_excerpt(); ?></div>
	<?php endif; ?>
</article>

content-none.php covers an empty result: an <h1> "Nothing found", and on a search (is_search()) a hint and get_search_form(), otherwise "Nothing has been published here yet.", all through esc_html_e().

The theme has no comments.php and does not call comments_template(), so comments are not shown; a theme that supports comments ships its own comments.php (see Categories, Tags and Comments).

Template tags

Template tags are the functions templates call to print data about the current post, the site or the request. Most come in two forms: the_*() echoes the value, already filtered for display, and get_the_*() returns it. Use the get_ form when the value is processed or placed somewhere that needs its own escaping (an attribute, a JSON blob).

Tag Prints

the_title( $before, $after ) / get_the_title()

The title, after the the_title filter (texturize, & as &#038;). Not escaped for attributes.

the_title_attribute()

The title with tags stripped and esc_attr() applied, for use inside an HTML attribute.

the_content() / get_the_content()

The full content through the the_content filter: block rendering, shortcodes, embeds, wpautop for classic content. The returned form skips the filter.

the_excerpt()

The manual excerpt, or the first 55 words of the content (excerpt_length and excerpt_more filters).

the_permalink() / get_permalink()

The post URL; the_permalink() applies esc_url() itself.

the_ID() / get_the_ID()

The post ID.

the_date(), get_the_date( $format ), the_time()

The publication date. the_date() prints the date only for the first post of each day in a Loop; use get_the_date() for a date on every post.

the_author(), get_the_author(), the_author_posts_link()

The author’s display name and archive link.

the_category(), the_tags(), the_terms( $id, $taxonomy ), get_the_term_list()

Linked term lists, including custom taxonomies such as lp_genre.

the_post_thumbnail( $size ), has_post_thumbnail()

The featured image as an <img> with srcset, sizes and lazy loading. Needs the post-thumbnails support.

the_posts_pagination(), the_post_navigation(), wp_link_pages()

Page links for the Loop, previous/next post links, and links between the pages of a paginated post.

bloginfo( 'name' ), home_url(), get_search_form(), edit_post_link()

Site name and URLs, the search form, an edit link for users who can edit.

Most post tags also accept a post ID or object (get_the_title( 42 )), so code outside the Loop needs no globals.

Conditional tags

Conditional tags answer questions about the current request (is_single(), is_archive(), is_search()) or the current post (has_post_thumbnail(), in_the_loop()). The request tags read the main query, so they only work once it has run: called earlier (for example in a plugin’s main file, before $wp_query exists) they return false and log "Conditional query tags do not work before the query is run". The wp action, template_redirect and the templates themselves are safe places.

The script below parses each path as a front-end request, in the same way index.php does, and prints the request tags that are true. It runs with wp eval-file against the test site of Rendering the theme, where Settings > Reading shows a static page ("Home") on the front page and the posts on "News".

<?php
/**
 * Parses each path as a front-end request and prints the conditional tags that are true.
 * Usage: wp eval-file conditionals.php / /news/ /books/ ...
 */

$lumen_tags = array( 'is_front_page', 'is_home', 'is_singular', 'is_single', 'is_page', 'is_archive',
	'is_post_type_archive', 'is_tax', 'is_search', 'is_404' );

foreach ( $args as $lumen_path ) {
	$_SERVER['REQUEST_URI'] = $lumen_path;
	parse_str( (string) wp_parse_url( $lumen_path, PHP_URL_QUERY ), $_GET );
	$GLOBALS['wp']->matched_rule = null; // Forget the previous request's rewrite match.
	$GLOBALS['wp']->main();

	$lumen_true = array_filter( $lumen_tags, function ( $tag ) {
		return call_user_func( $tag );
	} );
	printf( "%-30s %s%s\n", $lumen_path, implode( ' ', $lumen_true ),
		is_singular( 'lp_book' ) ? " is_singular('lp_book')" : '' );
}
$ wp eval-file conditionals.php / /news/ /2026/10/autumn-catalogue/ /books/ /books/the-glass-orchard/ \
    /genre/literary-fiction/ '/?s=orchard' /no-such-page/
/                              is_front_page is_singular is_page
/news/                         is_home
/2026/10/autumn-catalogue/     is_singular is_single
/books/                        is_archive is_post_type_archive
/books/the-glass-orchard/      is_singular is_single is_singular('lp_book')
/genre/literary-fiction/       is_archive is_tax
/?s=orchard                    is_search
/no-such-page/                 is_404

The output shows the distinctions that cause most template bugs:

  • is_home() means the blog posts index, not the home page. With a static front page it is true on /news/ and false on /. is_front_page() is true for the site root whichever it shows. Only with "Your latest posts" are both true on /.

  • is_single() is true for posts and custom post type entries but not for pages; is_page() only for pages; is_singular() for all three, and is_singular( 'lp_book' ) narrows it to one post type.

  • A post type archive is is_post_type_archive() (and is_post_type_archive( 'lp_book' )), a custom taxonomy archive is_tax() (is_tax( 'lp_genre', 'literary-fiction' )); categories and tags have is_category() and is_tag(), and is_tax() is false for them.

  • is_archive() covers every list except the blog index and search results.

Other common ones are is_paged(), is_user_logged_in(), comments_open(), has_term() and in_the_loop(). Conditional tags select what to show; current_user_can() decides what a user may do.

Secondary loops

A secondary loop lists posts other than those of the main query: related books, the latest news in a sidebar, a featured section. It creates its own WP_Query and calls the object’s methods, never the bare have_posts() and the_post(), which belong to the main query:

  • $query->the_post() still overwrites the global $post, because template tags read only the global.

  • After the secondary loop, wp_reset_postdata() puts the main query’s current post back into $post and the template-tag globals. Without it, everything printed later (comments, an edit link, a plugin’s the_content filter, schema markup in wp_footer) describes the last related book instead of the page’s own.

single.php above runs its secondary loop after the main Loop and takes the book’s ID from get_queried_object_id(), which reads the main query and is not affected by the globals. The script below shows what happens when a secondary loop runs inside the main one, before and after the reset:

<?php
/**
 * Runs a main loop with a secondary loop inside it, with and without wp_reset_postdata().
 * Usage: wp eval-file loop-demo.php
 */

global $wp_query, $wp_the_query;
$wp_query     = new WP_Query( array( 'post_type' => 'lp_book', 'name' => 'the-glass-orchard' ) );
$wp_the_query = $wp_query;

while ( have_posts() ) {
	the_post();
	printf( "main loop:      %s (in_the_loop: %s)\n", get_the_title(), in_the_loop() ? 'yes' : 'no' );

	$related = new WP_Query( array( 'post_type' => 'lp_book', 'posts_per_page' => 2, 'post__not_in' => array( get_the_ID() ) ) );
	while ( $related->have_posts() ) {
		$related->the_post();
		printf( "  secondary:    %s (main query: %s)\n", get_the_title(), $related->is_main_query() ? 'yes' : 'no' );
	}
	printf( "before reset:   %s\n", get_the_title() );
	wp_reset_postdata();
	printf( "after reset:    %s\n", get_the_title() );
}
printf( "after the loop: in_the_loop: %s\n", in_the_loop() ? 'yes' : 'no' );
$ wp eval-file loop-demo.php
main loop:      The Glass Orchard (in_the_loop: yes)
  secondary:    Salt &#038; Paper (main query: no)
  secondary:    Night Ferry (main query: no)
before reset:   Night Ferry
after reset:    The Glass Orchard
after the loop: in_the_loop: no

Between the secondary loop and the reset, get_the_title() returns the last related book. The & in the title is the the_title filter at work: titles come back ready for HTML.

A secondary query should always set posts_per_page (the default is the Settings > Reading value, and -1 fetches everything), and 'no_found_rows' => true when it is not paginated, which skips the SQL_CALC_FOUND_ROWS count. 'ignore_sticky_posts' => true keeps sticky blog posts from being prepended to the list.

get_posts() returns an array (with suppress_filters, ignore_sticky_posts and no_found_rows on by default); looping over it with template tags needs setup_postdata() per item and wp_reset_postdata() afterwards, while tags given a post argument (get_the_title( $book )) need neither. See WP_Query and the Database API.

Changing the main query

Templates must not replace the main query. query_posts() does exactly that: it discards the query WordPress ran, runs a new one (doubling the database work), replaces $wp_query and breaks pagination and conditional tags. It is still in core but documented as not intended for themes or plugins; if it is used, wp_reset_query() (not wp_reset_postdata()) restores $wp_query from $wp_the_query. The supported way to change what the main query fetches is the pre_get_posts action. The Lumen\Catalog\Queries class of lumen-catalog returns early unless ! is_admin() && $query->is_main_query() and the query is the lp_book archive or an lp_genre or lp_series archive, and then sets 24 books per page ordered by the lp_published_on meta, newest first, then by title; /books/ and its taxonomy archives follow that order while /news/ keeps the Reading setting. The class and the SQL it produces are in WP_Query and the Database API. The is_main_query() guard is essential, because the action fires for every WP_Query, secondary queries and admin screens included; inside the callback use the methods of $query, not the global conditional tags.

Escaping in templates

A template is where data turns into HTML, so it is where it gets escaped. The rule in the WordPress coding standards is escape late: at the moment of output, with the function that matches the context, even for data that was sanitized when it was saved.

Context Function

Text between tags

esc_html( $value ); for translated strings esc_html_e() and esc_html__().

An HTML attribute

esc_attr( $value ), or the_title_attribute() for the title.

A URL in href or src

esc_url( $url ). the_permalink() already does it; get_permalink() does not.

HTML that may contain markup

wp_kses_post( $html ) (the tags allowed in post content) or wp_kses() with an explicit allow-list.

In the 7.1.3 source, the_permalink() applies esc_url() and bloginfo() applies esc_html (through the bloginfo filter), so both are safe as used above. the_title() is filtered but not escaped: titles may carry markup that wp_kses allowed for users without unfiltered_html, which is fine as text and wrong in an attribute. the_content() and the_excerpt() print HTML on purpose, since the content was filtered when it was saved. Post meta, options, request values and remote API data are never escaped by WordPress, hence esc_html( $lumen_isbn ).

Two template-specific traps remain. First, never pass request data to get_template_part() or locate_template(): the name becomes part of a file path. Since 7.1.2 locate_template() refuses candidates containing .. that resolve outside the theme and theme-compat folders (verified: locate_template( '../../../wp-config.php' ) returns an empty string and get_template_part() returns false), but an allow-list of names is still the correct design. Second, translated strings are data too: esc_html__() and esc_html_e() escape the translation, which a translator controls.

Escaping is checked automatically by the WordPress.Security.EscapeOutput sniff of the WordPress Coding Standards for PHP_CodeSniffer, which flags every unescaped echo; see Security for Developers.

Rendering the theme

The test site is the Compose stack of Template Hierarchy (MariaDB 11.8, the wordpress:7.1.3-php8.4-apache image answering as lumenpress.test, and a wordpress:cli-2.12.0-php8.4 container), with lumen-catalog and lumen-classic mounted read-only and the two scripts of this page mounted at /scripts. Inside the Compose network the site URL is http://lumenpress.test. The seed script installs WordPress, creates the canonical users, a static front page, a post and four books, and sets two posts per page so the pagination is visible:

set -e
wp core install --url=http://lumenpress.test --title="Lumen Press" --admin_user=admin \
  --admin_email=admin@lumenpress.test --admin_password="$WP_ADMIN_PASSWORD" --skip-email
wp config set WP_DEBUG true --raw
wp config set WP_DEBUG_LOG true --raw
wp option update posts_per_page 2
wp plugin activate lumen-catalog
wp theme activate lumen-classic
wp rewrite structure '/%year%/%monthnum%/%postname%/'
wp user create maria.editor maria@lumenpress.test --role=editor --user_pass="$WP_ADMIN_PASSWORD" --display_name='Maria Editor'
wp user create tom.author tom@lumenpress.test --role=author --user_pass="$WP_ADMIN_PASSWORD" --display_name='Tom Author'
wp post update 1 --post_author=3 --post_title='Autumn catalogue announced' --post_name=autumn-catalogue \
  --post_date='2026-10-01 10:00:00' --post_content='<!-- wp:paragraph --><p>Six new titles arrive in October.</p><!-- /wp:paragraph -->'
HOME_ID=$(wp post create --post_type=page --post_status=publish --post_title=Home --porcelain)
NEWS_ID=$(wp post create --post_type=page --post_status=publish --post_title=News --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 term create lp_genre 'Literary Fiction' --slug=literary-fiction
while IFS='|' read -r title slug isbn; do
  id=$(wp post create --post_type=lp_book --post_status=publish --post_title="$title" --post_name="$slug" \
    --post_excerpt='A novel from the Lumen Press list.' --porcelain \
    --post_content='<!-- wp:paragraph --><p>Full description to follow.</p><!-- /wp:paragraph -->')
  wp post meta update "$id" lp_isbn "$isbn"
  wp post term set "$id" lp_genre literary-fiction
done <<'BOOKS'
The Lantern Keeper|the-lantern-keeper|978-0-00-000001-9
Night Ferry|night-ferry|978-1-23456-793-4
Salt & Paper|salt-and-paper|978-0-00-000004-0
The Glass Orchard|the-glass-orchard|978-0-00-000003-3
BOOKS
wp rewrite flush

Every file passed php -l under PHP 7.4 and 8.4; then one request of each kind was sent from the WP-CLI container:

docker compose exec -T cli sh /scripts/seed.sh
docker compose exec -T cli sh -c 'for path in / /news/ /2026/10/autumn-catalogue/ /books/ /books/page/2/ \
    /books/the-glass-orchard/ /genre/literary-fiction/ "/?s=orchard" /no-such-page/; do
  curl -s -o /dev/null -w "%{http_code} $path\n" "http://lumenpress.test$path"; done'
docker compose exec -T cli curl -s http://lumenpress.test/books/the-glass-orchard/

Every path returned 200 except /no-such-page/ (404), and wp-content/debug.log stayed empty: no notice or deprecation from the theme. The body of the single book page (whitespace trimmed):

<body class="wp-singular lp_book-template-default single single-lp_book postid-11 wp-theme-lumen-classic">
<!-- skip link and site header from header.php -->
<main id="content" class="site-main">
<article id="book-11" class="book post-11 lp_book type-lp_book status-publish hentry lp_genre-literary-fiction">
  <h1 class="entry-title">The Glass Orchard</h1>
  <dl class="book-details">
    <dt>ISBN</dt>
    <dd>978-0-00-000003-3</dd>
    <dt>Genre</dt><dd><a href="http://lumenpress.test/genre/literary-fiction/" rel="tag">Literary Fiction</a></dd>
  </dl>
  <div class="entry-content"><p class="wp-block-paragraph">Full description to follow.</p></div>
</article>
<section class="related-books" aria-labelledby="related-books-title">
  <h2 id="related-books-title">More in this genre</h2>
  <ul>
    <li><a href="http://lumenpress.test/books/salt-and-paper/">Salt &#038; Paper</a></li>
    <li><a href="http://lumenpress.test/books/night-ferry/">Night Ferry</a></li>
    <li><a href="http://lumenpress.test/books/the-lantern-keeper/">The Lantern Keeper</a></li>
  </ul>
</section>
</main>
<!-- site footer and wp_footer() output from footer.php -->

The <head> held the <title>The Glass Orchard – Lumen Press</title> printed by title-tag, the lumen-classic-style-css link and the canonical URL, all from wp_head(); the footer ended with the speculative loading rules and the emoji loader from wp_footer(). The /books/ archive printed two books and a <nav class="navigation pagination" aria-label="Posts pagination"> with links to page 2 and the custom "Older books" label. The front page printed the site name as an <h1>, a search for a missing word printed content-none.php with the core search form, and docker compose down -v removed the site.

What older books and tutorials get wrong or leave out

Older advice Current state (7.1)

Themes are a set of PHP templates, functions.php and the Customizer, with no mention of block themes (Pro WordPress)

That describes classic themes, which still work. Block themes (5.9) are the default, and their templates are HTML block markup; see Themes Overview and Hybrid Themes and Migration to Block Themes.

Print the <title> in header.php with wp_title()

Declare add_theme_support( 'title-tag' ) (4.1) and let wp_head() print it.

Link the stylesheet and scripts with <link> and <script> tags in header.php

Enqueue them with wp_enqueue_style() and wp_enqueue_script() on wp_enqueue_scripts, so dependencies, versions and plugins work.

header.php without wp_body_open()

Call it right after <body> (5.2); the Theme Directory requires it.

Change the Loop with query_posts() at the top of a template

Use pre_get_posts for the main query and a separate WP_Query with wp_reset_postdata() for extra lists.

Pass data to template parts through globals or set_query_var()

Use the $args parameter of get_template_part() and get_header() (5.5).

Echo get_post_meta(), get_permalink() or get_the_title() in attributes unescaped

Escape late with esc_html(), esc_url(), esc_attr() or the_title_attribute().

Register the book post type in the theme’s functions.php

Plugin territory: the content disappears on a theme switch. Lumen Press registers it in lumen-catalog.

The All-in-One For Dummies and Ultimate WordPress Handbook books do not cover template files or The Loop.

Common errors and pitfalls

Symptom Cause and fix

Plugin styles or scripts missing, admin bar absent, no <title>

wp_head() or wp_footer() missing from header.php/footer.php. Add both.

After a "related posts" list, the comments, edit link or schema describe another post

wp_reset_postdata() missing after a secondary loop.

Pagination on an archive shows the wrong pages or 404s on page 2

A template replaced the main query (query_posts() or a new WP_Query used as the list). Move the change to pre_get_posts.

is_home() is false on the home page

The site shows a static front page; use is_front_page(). is_home() means the posts index.

A conditional tag always returns false in a plugin

Called before the query ran (a "called incorrectly" notice). Run the code on wp, template_redirect or later.

get_template_part( 'template-parts/content', 'lp_book' ) loads content.php

content-lp_book.php is missing or misnamed (hyphen, not underscore, between slug and name).

A title with quotes breaks an aria-label or title attribute

the_title() was used in the attribute; use the_title_attribute().

Security notes: templates run with the full privileges of WordPress, so the attack surface of a classic theme is its output. Escape every value at output, keep request data out of get_template_part() names and include paths, and keep secondary queries bounded with posts_per_page. The theme file editor should be disabled with DISALLOW_FILE_EDIT, as described in Security Hardening.