1. A Minimal Theme
A WordPress theme is a folder in wp-content/themes/ with, at minimum,
two files:
/*
Theme Name: My Theme
Author: You
Version: 1.0
*/
<?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.
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-{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 specificallyhome.php— the blog posts index (if different from the front page)single.php— one blog postpage.php— one static pagearchive.php— a category, tag, date, or custom-post-type archive listing404.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:
<?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:
<?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;
?>
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:
<?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)
<?php
function my_theme_setup() {
add_theme_support('post-thumbnails');
add_theme_support('title-tag');
}
add_action('after_setup_theme', 'my_theme_setup');
<?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
Build a minimal blog theme from scratch
A real, working theme with its own header, footer, Loop and enqueued assets.
Requirements:
- Create a theme folder with
style.css,index.php,header.php,footer.phpandfunctions.php. Activate it withwp theme activate. - Build a proper Loop in
index.phpshowing title, date, author and excerpt for each post, with pagination viathe_posts_pagination(). - Create
single.phprendering one full post withthe_content()instead ofthe_excerpt(). - Enqueue a theme stylesheet and a JS file properly via
wp_enqueue_style/wp_enqueue_script, hooked towp_enqueue_scripts. - Add a filter changing the "read more" link text on excerpts, and an action adding
post-thumbnailssupport. - Add a "Related posts" block on
single.phpusing a customWP_Query, correctly callingwp_reset_postdata()afterward.
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?
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?
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?
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?
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.