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 |
|---|---|
|
Theme header (name, version, requirements, text domain) and, usually, the main stylesheet. Required. |
|
Last fallback of the template hierarchy; renders any request no other file matches. Required. |
|
Loaded on every request before the template; declares theme supports and enqueues assets. Optional. |
|
Partials loaded by |
|
The site front page, and the blog posts index (the front page by default, or the "Posts page" in Settings > Reading). |
|
A single post, entry of a custom post type, or page. |
|
Lists of posts: post type archives, term archives, author and date archives. |
|
Search results, "not found", and attachment pages (off by default since 6.4). |
|
Partials loaded by |
|
Partials loaded with |
Source: Theme Handbook: Template Files
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.
Header, footer and template parts
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 andset_query_var()calls older themes used. -
Return value (5.5).
get_template_part(),get_header()and the others returnfalsewhen 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.phporcomments.php, WordPress loads the copy inwp-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">
Source: Theme Handbook: Template Files
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>© <?php echo esc_html( wp_date( 'Y' ) ); ?> <?php bloginfo( 'name' ); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>
Source: Code Reference: wp_footer()
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 |
|---|---|---|
|
Just before |
Enqueued styles and scripts (the theme’s own |
|
Right after |
Plugins that must print first in the body (tag managers, skip links, consent banners). Required by the Theme Directory. |
|
Just before |
Footer scripts (most enqueued JavaScript), the admin bar itself, speculative loading rules, emoji support. |
|
On |
The |
|
On |
The classes ( |
|
On each post’s wrapper |
Per-post classes: |
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();
Source: Theme Handbook: The Loop
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()returnstruewhile posts remain. When it runs past the last post it fires theloop_endaction and callsrewind_posts(), so a second Loop over the same query starts from the beginning. When the query found nothing it firesloop_no_results(4.9) instead. -
the_post()advances to the next post, sets the global$postand callssetup_postdata(), which fills the globals that template tags read ($id,$authordata,$pages,$page,$moreand others) and fires thethe_postaction. On the first call it also firesloop_startand 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).
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();
Source: Theme Handbook: The Loop
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>
Source: Theme Handbook: Template Tags
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>
Source: Code Reference: get_post_meta()
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, after the |
|
The title with tags stripped and |
|
The full content through the |
|
The manual excerpt, or the first 55 words of the content ( |
|
The post URL; |
|
The post ID. |
|
The publication date. |
|
The author’s display name and archive link. |
|
Linked term lists, including custom taxonomies such as |
|
The featured image as an |
|
Page links for the Loop, previous/next post links, and links between the pages of a paginated post. |
|
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')" : '' );
}
Source: Theme Handbook: Conditional Tags
$ 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, andis_singular( 'lp_book' )narrows it to one post type. -
A post type archive is
is_post_type_archive()(andis_post_type_archive( 'lp_book' )), a custom taxonomy archiveis_tax()(is_tax( 'lp_genre', 'literary-fiction' )); categories and tags haveis_category()andis_tag(), andis_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$postand the template-tag globals. Without it, everything printed later (comments, an edit link, a plugin’sthe_contentfilter, schema markup inwp_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 & 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.
Source: Code Reference: WP_Query
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.
Source: Code Reference: pre_get_posts
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 |
|
An HTML attribute |
|
A URL in |
|
HTML that may contain markup |
|
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
Source: WP-CLI Commands: wp post create
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/
Source: WP-CLI Commands: wp eval-file
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 & 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, |
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 |
Declare |
Link the stylesheet and scripts with |
Enqueue them with |
|
Call it right after |
Change the Loop with |
Use |
Pass data to template parts through globals or |
Use the |
Echo |
Escape late with |
Register the book post type in the theme’s |
Plugin territory: the content disappears on a theme switch. Lumen Press registers it in |
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 |
|
After a "related posts" list, the comments, edit link or schema describe another post |
|
Pagination on an archive shows the wrong pages or 404s on page 2 |
A template replaced the main query ( |
|
The site shows a static front page; use |
A conditional tag always returns |
Called before the query ran (a "called incorrectly" notice). Run the code on |
|
|
A title with quotes breaks an |
|
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.