Week 18: Theme Development I — Template Hierarchy & the Loop

This week builds a WordPress theme from an empty folder — the template hierarchy that decides which file renders a given URL, The Loop that turns query results into HTML, and a first look at the hooks system that makes WordPress extensible without editing core.

Module 17 of 28 Week 18 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build a minimal working theme & understand the template hierarchy
  • Render posts with The Loop, correctly
  • Use your first actions & filters — the hooks system WordPress is built on

1. A Minimal Theme

A WordPress theme is a folder in wp-content/themes/ with, at minimum, two files:

wp-content/themes/my-theme/style.css
/*
Theme Name: My Theme
Author: You
Version: 1.0
*/
wp-content/themes/my-theme/index.php
<?php get_header(); ?>

<main>
  <h1>Hello from my first theme</h1>
</main>

<?php get_footer(); ?>

style.css's comment header is how WordPress identifies and lists the theme — the actual stylesheet content is separate and enqueued properly later in this lesson. get_header()/get_footer() pull in header.php/footer.php from the same theme folder, the same "shared layout" idea as Laravel's @extends('layouts.app') from Week 9, just wired through file naming convention instead of an explicit directive.

terminal
wp theme activate my-theme

2. The Template Hierarchy

WordPress picks which template file renders a given URL through a defined fallback order — the template hierarchy. For a single blog post, it checks (in order) until it finds a match:

single post template hierarchy, most to least specific
single-post-{slug}.php
single-post.php
single.php
singular.php
index.php   ← the fallback every theme must have

Common templates worth creating deliberately as a theme grows:

  • front-page.php — the site's homepage specifically
  • home.php — the blog posts index (if different from the front page)
  • single.php — one blog post
  • page.php — one static page
  • archive.php — a category, tag, date, or custom-post-type archive listing
  • 404.php — not found

This is a genuinely different mental model from Laravel's explicit Route::get(...) declarations — WordPress infers which template to use from the URL and the content type being requested, following the file-naming convention above, with no route file to consult.

3. The Loop

The Loop is the standard pattern for rendering a set of posts — nearly every template file that outputs post content uses this exact shape:

index.php — a real Loop
<?php get_header(); ?>

<main>
  <?php if (have_posts()) : ?>
    <?php while (have_posts()) : the_post(); ?>
      <article>
        <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
        <p><?php the_date(); ?> by <?php the_author(); ?></p>
        <div><?php the_excerpt(); ?></div>
      </article>
    <?php endwhile; ?>

    <?php the_posts_pagination(); ?>
  <?php else : ?>
    <p>No posts found.</p>
  <?php endif; ?>
</main>

<?php get_footer(); ?>

have_posts()/the_post() iterate over WordPress's global query results (already fetched behind the scenes, based on the current URL and the template hierarchy match), and functions like the_title(), the_permalink() echo directly rather than returning a value — a WordPress-specific convention worth internalizing (their get_the_title() counterparts return instead of echo, useful when you need the value rather than immediate output).

For content outside the main query — a "related posts" block, for example — use a custom WP_Query instead of the automatic global one:

a custom query
<?php
$recent = new WP_Query([
    'post_type' => 'post',
    'posts_per_page' => 3,
    'orderby' => 'date',
    'order' => 'DESC',
]);

if ($recent->have_posts()) :
    while ($recent->have_posts()) : $recent->the_post(); ?>
      <h3><?php the_title(); ?></h3>
    <?php endwhile;
    wp_reset_postdata(); // critical -- restores the main query's global state
endif;
?>
Always call wp_reset_postdata() after a custom Loop

Functions like the_title() read from a global $post variable that a custom WP_Query temporarily overwrites. Forgetting wp_reset_postdata() leaves that global pointing at the wrong post for any Loop code that runs afterward — a subtle bug that only shows up once something else on the page also expects the main query's context.

4. Enqueueing Assets

Never link CSS/JS with a raw <link>/<script> tag — WordPress's enqueue system avoids version conflicts between plugins and themes that both need the same library:

functions.php
<?php

function my_theme_assets() {
    wp_enqueue_style(
        'my-theme-style',
        get_stylesheet_uri(), // this theme's own style.css
        [],
        '1.0'
    );

    wp_enqueue_script(
        'my-theme-script',
        get_template_directory_uri() . '/js/main.js',
        [], // dependencies -- e.g. ['jquery'] if this script needs it
        '1.0',
        true // load in the footer
    );
}
add_action('wp_enqueue_scripts', 'my_theme_assets');

add_action('wp_enqueue_scripts', 'my_theme_assets') is this lesson's first real look at hooks — covered properly next. For now, the pattern to notice: asset loading isn't called directly; it's registered against a named point in WordPress's execution, and WordPress calls it at the right time.

5. Actions & Filters

Hooks are how WordPress code — core, themes, and plugins — lets other code run at specific points without editing the original file. There are two kinds:

  • Actions — run code at a named point; they don't return anything (add_action/do_action)
  • Filters — modify a value and return the modified version (add_filter/apply_filters)
functions.php — an action
<?php

function my_theme_setup() {
    add_theme_support('post-thumbnails');
    add_theme_support('title-tag');
}
add_action('after_setup_theme', 'my_theme_setup');
functions.php — a filter
<?php

function my_theme_excerpt_length(int $length): int {
    return 20; // override the default excerpt word count
}
add_filter('excerpt_length', 'my_theme_excerpt_length');

function my_theme_excerpt_more(string $more): string {
    return '… <a href="' . get_permalink() . '">Read more</a>';
}
add_filter('excerpt_more', 'my_theme_excerpt_more');

The naming convention is the tell: excerpt_length receives a value (the default length) and must return a value (your desired length) — that's a filter. after_setup_theme just runs your function at a point in WordPress's boot sequence — that's an action. This exact mechanism is what Weeks 20–21 build entire plugins around.

6. Hands-on Exercise

Hands-on

Build a minimal blog theme from scratch

A real, working theme with its own header, footer, Loop and enqueued assets.

Requirements:

  1. Create a theme folder with style.css, index.php, header.php, footer.php and functions.php. Activate it with wp theme activate.
  2. Build a proper Loop in index.php showing title, date, author and excerpt for each post, with pagination via the_posts_pagination().
  3. Create single.php rendering one full post with the_content() instead of the_excerpt().
  4. Enqueue a theme stylesheet and a JS file properly via wp_enqueue_style/wp_enqueue_script, hooked to wp_enqueue_scripts.
  5. Add a filter changing the "read more" link text on excerpts, and an action adding post-thumbnails support.
  6. Add a "Related posts" block on single.php using a custom WP_Query, correctly calling wp_reset_postdata() afterward.
Hint

Forgetting wp_reset_postdata() often shows up as the wrong title or content appearing right after the related-posts block, wherever the main Loop resumes — if that happens, it's almost always this missing call.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Visiting a single blog post, but the theme has no single.php — what actually renders it?

WordPress falls back through the template hierarchy — singular.php if it exists, and ultimately index.php, which every theme is required to have as the final fallback. The hierarchy is a defined, predictable search order, not a guess; understanding it means you always know exactly which file is responsible for rendering a given URL.

Q2

What's the actual difference between an action and a filter?

An action runs your code at a named point and doesn't expect anything back — it's for "do something here" (enqueueing assets, registering theme support). A filter receives a value, and your hooked function must return a (possibly modified) value — it's for "change this value before WordPress uses it" (like excerpt_length, which expects an integer back).

Q3

Why enqueue theme assets through wp_enqueue_style/wp_enqueue_script instead of a plain <link>/<script> tag in header.php?

The enqueue system tracks dependencies and versions across every theme and plugin on the site — if a plugin also needs jQuery, WordPress loads it once at the correct version rather than potentially loading multiple conflicting copies from a plain tag. It also lets other code deregister or modify an enqueued asset later if genuinely necessary, which a hardcoded tag makes impossible to intercept.

Q4

Why does a custom WP_Query for a "related posts" block need wp_reset_postdata() afterward?

Calling the_post() on the custom query temporarily overwrites the global $post variable that Loop functions like the_title() and the_content() read from. Without wp_reset_postdata(), that global stays pointed at the last post from the custom query instead of being restored to the main query's post — any Loop code running afterward on the same page would render the wrong content.