Chapter 14 — Front-End Fundamentals

WP Logic and functions.php

You’ve been using add_action() since Chapter 12. You enqueued your assets with it, registered nav menus with it, and declared theme support with it. What you haven’t had yet is the explanation of what it actually does. That’s this chapter. Hooks are the mechanism behind almost everything a theme or plugin does, and once the model clicks, the rest of WordPress development gets a lot less mysterious.

What functions.php is, and what it isn’t

functions.php is your theme’s logic file. WordPress loads it automatically on every request the theme handles, before any template renders. Anything you put there runs.

The important word is theme. functions.php is scoped to the theme it lives in. Activate a different theme and your functions.php stops running entirely. Every function, every hook, every customization in it goes silent.

That scoping is a design decision, and it tells you where code belongs. If you’re writing something that exists to support how this theme presents content (a helper that formats a date for your template parts, an enqueue call for your stylesheet, a tweak to excerpt length), functions.php is the right home. If you’re writing something that should outlive the theme (a custom post type holding a client’s content, a plugin you’ll reuse across sites, an integration with a third-party service), it does not belong here. It belongs in a plugin. Chapter 16 builds one.

A useful test: imagine the client switches themes a year from now. Should this feature survive that switch? If yes, it’s a plugin. If the feature only makes sense in the context of this specific design, it’s functions.php.

Hooks: actions and filters

WordPress runs through a long sequence of steps to build a page. It loads the environment, parses the request, queries the database, picks a template, renders it, sends it to the browser. At dozens of points along that sequence, WordPress stops and asks a question: does anyone want to run code here?

That question is a hook. Your code answers it.

There are two kinds of hook, and the difference matters.

Actions let you run code at a specific point in WordPress’s execution. They don’t expect anything back. add_action( 'wp_enqueue_scripts', 'tutorials_enqueue_assets' ) tells WordPress: when you reach the wp_enqueue_scripts point, call my tutorials_enqueue_assets function. WordPress reaches that point, calls your function, your function enqueues some files, done. Nothing is returned because nothing needs to be.

Filters let you modify a piece of data before WordPress uses it. They always expect a return value. add_filter( 'the_content', 'tutorials_content_filter' ) says: before you render post content, hand it to my tutorials_content_filter function first. Your function receives the content as an argument, changes it, and returns the changed version. WordPress then renders whatever you returned.

The mental model: WordPress broadcasts events as it runs. Your code subscribes to the events it cares about. An action subscriber does something. A filter subscriber receives data, transforms it, and passes it back. WordPress core never has to know your code exists, and your code never has to modify a core file. That’s the whole reason the system exists.

The other half of the system is the code that fires hooks. do_action( 'tutorials_after_header' ) fires an action hook, the counterpart to add_action(). apply_filters( 'tutorials_excerpt_more', $default ) fires a filter hook, the counterpart to add_filter(). WordPress core is full of do_action() and apply_filters() calls. You can add your own when you want to make your theme extensible:

php~/tutorials/wp-content/themes/tutorials/footer.php
<footer class="site-footer">
  <div class="wrap">
    <?php do_action( 'tutorials_footer_top' ); ?>
    <p class="footer-copy">
      &copy; <?php echo esc_html( date( 'Y' ) ); ?>
      <?php bloginfo( 'name' ); ?>
    </p>
  </div>
</footer>

Any code, anywhere, can now hook tutorials_footer_top and inject markup there without touching footer.php. That’s do_action() on the giving end. add_action() is the receiving end.

add_action in detail

The full signature is:

php~/tutorials/
add_action( $hook_name, $callback, $priority, $accepted_args );

The first two arguments you’ve seen. $hook_name is the hook to attach to. $callback is the function to run. The last two have defaults.

$priority is an integer, defaulting to 10. When several functions are attached to the same hook, priority decides the order. Lower numbers run first. A function at priority 5 runs before one at priority 20. If two functions share a priority, they run in the order they were added.

$accepted_args is the number of arguments your callback should receive, defaulting to 1. Most hooks pass useful data along, and if your callback needs more than the first argument, you raise this number.

Priority is easy to see in action. Attach two functions to the same hook:

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
function tutorials_first_message() {
	echo '<!-- runs first -->';
}
add_action( 'wp_footer', 'tutorials_first_message', 5 );

function tutorials_second_message() {
	echo '<!-- runs second -->';
}
add_action( 'wp_footer', 'tutorials_second_message', 20 );

The comment from tutorials_first_message appears before the one from tutorials_second_message in the page source, because 5 is lower than 20. Swap the numbers and the order flips.

The action hooks you’ll reach for most often:

after_setup_theme fires early, right after the theme loads. It’s where theme initialization belongs: declaring support, registering menus, registering image sizes.

wp_enqueue_scripts fires when WordPress is collecting front-end assets. Styles and scripts get enqueued here.

init fires after WordPress has loaded but before any request is handled. Custom post types and taxonomies get registered on init. So do rewrite rules.

wp_head and wp_footer fire inside the <head> and just before </body>. Hook these when you need to inject markup, like a meta tag or an analytics snippet, directly into the page.

add_filter in detail

The signature mirrors add_action():

php~/tutorials/
add_filter( $hook_name, $callback, $priority, $accepted_args );

The one rule that separates filters from actions: a filter callback must return a value. The hook passes data in, your function changes it, and the return value is what WordPress uses going forward. Forget the return and you’ve handed WordPress nothing.

A handful of filters cover most theme work.

excerpt_length controls how many words an auto-generated excerpt contains. The default is 55. To shorten it:

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
function tutorials_excerpt_length( $length ) {
	return 25;
}
add_filter( 'excerpt_length', 'tutorials_excerpt_length' );

WordPress hands your function the current length (55), and you return the length you want (25). The number you return is the number WordPress uses.

body_class controls the array of classes printed by body_class() in header.php. To add a class of your own:

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
function tutorials_body_classes( $classes ) {
	if ( is_front_page() ) {
		$classes[] = 'is-front-page';
	}
	return $classes;
}
add_filter( 'body_class', 'tutorials_body_classes' );

You receive the existing array, push a class onto it, and return the whole array. Return only your single class and you’ve thrown away every class WordPress generated.

the_title lets you modify post titles before they print. the_content lets you modify post content. Both are powerful and both are easy to misuse, so reach for them deliberately.

Here is the mistake worth seeing once so you recognize it later:

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
// Wrong: no return statement.
function tutorials_broken_excerpt_length( $length ) {
	$new_length = 25;
	// forgot to return $new_length
}
add_filter( 'excerpt_length', 'tutorials_broken_excerpt_length' );

A PHP function with no return returns null. WordPress takes that null as the new excerpt length, and your excerpts break. When a filter makes content vanish or behave strangely, the missing return statement is the first thing to check.

Removing hooks

Sometimes a plugin, or WordPress core, attaches something you don’t want. You can detach it without editing the plugin, using remove_action() and remove_filter().

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
remove_action( 'wp_head', 'wp_generator' );

That one removes the <meta name="generator"> tag WordPress prints in the head, the tag that advertises which WordPress version the site runs.

Two things trip people up. The first: the priority you pass to remove_action() has to match the priority the hook was added with. If a plugin attached its callback at priority 20, remove_action( 'hook', 'callback', 20 ) removes it and remove_action( 'hook', 'callback' ) (priority 10 by default) does nothing at all. You have to look up how the original add_action() call was written.

The second: you can only remove a hook after it has been added. If a plugin adds its hook inside a function that runs on init, and you call remove_action() at the top level of functions.php, your removal runs first and removes nothing. The fix is to wrap your removal in a hook that fires later:

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
function tutorials_remove_plugin_hook() {
	remove_action( 'wp_footer', 'some_plugin_footer_callback' );
}
add_action( 'wp_loaded', 'tutorials_remove_plugin_hook' );

wp_loaded fires after init, so by the time your removal runs, the plugin’s hook is already in place to be removed.

Organizing functions.php

Chapter 12 set up a pattern: functions.php stays thin and only loads files from an inc/ directory. As the theme grows, that’s what keeps it readable. Splitting by concern means you always know which file to open.

A sensible breakdown:

text~/tutorials/
inc/
├── setup.php        Theme support, nav menus, image sizes
├── enqueue.php      Styles and scripts
├── helpers.php      Utility functions used in templates
└── post-types.php   Custom post types (only if the theme genuinely needs one)

functions.php becomes a list of requires:

php~/tutorials/wp-content/themes/tutorials/functions.php
<?php
/**
 * Tutorials theme functions.
 *
 * @package tutorials-theme
 */

require get_template_directory() . '/inc/setup.php';
require get_template_directory() . '/inc/enqueue.php';
require get_template_directory() . '/inc/helpers.php';

Two conventions are non-negotiable. Prefix every function with tutorials_ so it can’t collide with a WordPress core function or a plugin function that happens to share a name. PHP has one global namespace for functions, and a collision is a fatal error. And document each function with a docblock. PHPCS, which you set up in Chapter 11, will flag both an unprefixed function and a missing docblock.

Enqueueing scripts and styles

Chapter 12 enqueued your assets and explained the call line by line. It’s worth seeing once more as the canonical example of an action hook doing real work, and worth being precise about each argument.

php~/tutorials/wp-content/themes/tutorials/inc/enqueue.php
<?php
/**
 * Enqueue theme assets.
 *
 * @package tutorials-theme
 */

/**
 * Register and load the theme's stylesheet and script.
 */
function tutorials_enqueue_assets() {
	wp_enqueue_style(
		'tutorials-style',
		get_template_directory_uri() . '/assets/css/main.css',
		array(),
		wp_get_theme()->get( 'Version' )
	);

	wp_enqueue_script(
		'tutorials-script',
		get_template_directory_uri() . '/assets/js/main.js',
		array(),
		wp_get_theme()->get( 'Version' ),
		true
	);
}
add_action( 'wp_enqueue_scripts', 'tutorials_enqueue_assets' );

wp_enqueue_style() takes five arguments, four of which you’re using here. The first is the handle, a unique string name for this stylesheet. The second is the URL to the file. The third is an array of dependencies: handles of other stylesheets that must load before this one. Yours has none, so the array is empty. The fourth is a version string, appended to the URL as a query parameter so browsers fetch a fresh copy when it changes. Reading it from wp_get_theme()->get( 'Version' ) means bumping the version in style.css busts the cache automatically.

wp_enqueue_script() takes the same first four arguments and one more. The fifth, true, tells WordPress to print the script tag in the footer rather than the head. Footer scripts don’t block the page from rendering, so unless a script must run before the page draws, load it in the footer.

The reason to enqueue at all, rather than dropping a <link> tag into header.php, is the dependency system. If you enqueue a script that depends on another, WordPress sorts the order for you. It also deduplicates: enqueue the same handle from two places and WordPress loads it once. A hardcoded <link> tag can do none of that.

Declaring theme support

add_theme_support() opts the theme into features WordPress offers but doesn’t assume. It belongs on the after_setup_theme hook.

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
function tutorials_setup() {
	add_theme_support( 'title-tag' );
	add_theme_support( 'post-thumbnails' );
	add_theme_support( 'html5', array( 'search-form', 'comment-form', 'gallery', 'caption' ) );
	add_theme_support( 'custom-logo' );
}
add_action( 'after_setup_theme', 'tutorials_setup' );

title-tag hands the <title> element to WordPress, which then generates a correct, per-page title without you writing one. post-thumbnails enables featured images, the images the_post_thumbnail() prints in your template parts. The html5 array asks WordPress to output modern, semantic markup for the listed elements instead of its older default markup. custom-logo adds a logo upload field to the Customizer, which you can then print with the_custom_logo().

Registering navigation menus and widget areas

register_nav_menus() defines named locations where an admin-managed menu can be placed. You registered primary in Chapter 12; header.php prints it with wp_nav_menu(). Adding a footer location is the same pattern:

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
register_nav_menus(
	array(
		'primary' => esc_html__( 'Primary Navigation', 'tutorials-theme' ),
		'footer'  => esc_html__( 'Footer Navigation', 'tutorials-theme' ),
	)
);

The array key is the location identifier you pass to wp_nav_menu() in a template. The value is the human-readable label that appears under Appearance > Menus in the admin. Registering a location doesn’t create a menu; it creates a slot. The admin builds the actual menu and assigns it to the slot.

Widget areas work the same way. register_sidebar() defines a region where an admin can drop widgets, and the theme prints that region with dynamic_sidebar():

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
function tutorials_widgets_init() {
	register_sidebar(
		array(
			'name'          => esc_html__( 'Footer Widgets', 'tutorials-theme' ),
			'id'            => 'footer-widgets',
			'description'   => esc_html__( 'Widgets shown in the site footer.', 'tutorials-theme' ),
			'before_widget' => '<section id="%1$s" class="widget %2$s">',
			'after_widget'  => '</section>',
			'before_title'  => '<h2 class="widget-title">',
			'after_title'   => '</h2>',
		)
	);
}
add_action( 'widgets_init', 'tutorials_widgets_init' );

The id, here footer-widgets, is what dynamic_sidebar( 'footer-widgets' ) references in footer.php. The before_widget and after_widget strings wrap each widget in markup you control, which keeps widget output consistent with the rest of your theme.

Registering custom image sizes

add_theme_support( 'post-thumbnails' ) turns on featured images. add_image_size() defines additional crops WordPress generates from each upload, so a template can request an image at exactly the dimensions it needs.

php~/tutorials/wp-content/themes/tutorials/inc/setup.php
add_image_size( 'tutorials-card', 600, 400, true );

The arguments are a name, a width, a height, and a crop flag. With the crop flag true, WordPress crops the image to exactly 600 by 400. With it false, WordPress scales the image to fit within those bounds without cropping. Once registered, the name becomes usable wherever a thumbnail is printed:

php~/tutorials/wp-content/themes/tutorials/template-parts/content.php
<?php the_post_thumbnail( 'tutorials-card' ); ?>

Requesting a registered size means the browser downloads an appropriately small file instead of a full-resolution image scaled down with CSS. Note one limitation: add_image_size() only affects images uploaded after it’s registered. Existing images need regenerating, which a plugin or a WP-CLI command can do.

Custom helper functions

Not every function in functions.php is a hook callback. Some are plain helpers you call directly from templates. The best candidates are small pieces of logic that would otherwise be repeated across template parts.

Take the post date. Chapter 13 prints it in three places: content.php, content-single.php, and the single-post header. Each one repeats the same <time> markup. Pull it into one helper:

php~/tutorials/wp-content/themes/tutorials/inc/helpers.php
<?php
/**
 * Template helper functions.
 *
 * @package tutorials-theme
 */

/**
 * Output the current post's publish date in a styled <time> element.
 *
 * @return void
 */
function tutorials_posted_on() {
	printf(
		'<time class="entry-date" datetime="%1$s">%2$s</time>',
		esc_attr( get_the_date( 'c' ) ),
		esc_html( get_the_date() )
	);
}

Now every template part calls tutorials_posted_on(). Change the date markup once and every template that uses it updates. The docblock’s @return void records that the function prints rather than returns, which matters to the next person reading the code.

A second common helper handles a missing featured image. Rather than every template checking has_post_thumbnail() and branching, write the branch once:

php~/tutorials/wp-content/themes/tutorials/inc/helpers.php
/**
 * Output the post thumbnail, or a fallback image if none is set.
 *
 * @param string $size Registered image size to request.
 * @return void
 */
function tutorials_thumbnail( $size = 'tutorials-card' ) {
	if ( has_post_thumbnail() ) {
		the_post_thumbnail( $size );
		return;
	}

	printf(
		'<img class="entry-thumbnail--fallback" src="%s" alt="" />',
		esc_url( get_template_directory_uri() . '/assets/img/placeholder.png' )
	);
}

The @param line documents the one argument: which registered size to request, defaulting to the tutorials-card size registered earlier. A template calls tutorials_thumbnail() and never has to think about the missing-image case again.

Pagination

Archives and the blog index show a limited number of posts per page. Without pagination links, a visitor can’t reach page two. paginate_links() generates those links.

The simplest place for it is a helper that wraps the call with sensible defaults:

php~/tutorials/wp-content/themes/tutorials/inc/helpers.php
/**
 * Output numbered pagination for the main query.
 *
 * @return void
 */
function tutorials_pagination() {
	the_posts_pagination(
		array(
			'mid_size'           => 2,
			'prev_text'          => esc_html__( 'Previous', 'tutorials-theme' ),
			'next_text'          => esc_html__( 'Next', 'tutorials-theme' ),
			'screen_reader_text' => esc_html__( 'Posts navigation', 'tutorials-theme' ),
		)
	);
}

the_posts_pagination() is a wrapper around paginate_links() built for the main query, which is what archive and index templates run. mid_size controls how many page numbers show on each side of the current page. prev_text and next_text label the previous and next links. screen_reader_text labels the navigation region for assistive technology, which Chapter 15 covers in full.

Call the helper after the loop closes in archive.php, home.php, and index.php:

php~/tutorials/wp-content/themes/tutorials/archive.php
    <?php endwhile; ?>

    <?php tutorials_pagination(); ?>

The functions.php versus plugin decision

Close out by stating the rule plainly, because it’s the question you’ll face on every WordPress project.

Theme-scoped presentation logic goes in functions.php. Enqueueing the theme’s stylesheet, registering its menus, defining image sizes the theme’s templates request, helper functions that format output for the theme’s template parts: all of it depends on this theme and would mean nothing without it.

Anything that should survive a theme change goes in a plugin. A custom post type holding a client’s portfolio or events or staff bios is the clearest example. That content belongs to the site, not to the design. Register it in functions.php and the day the client switches themes, the post type unregisters and their content becomes unreachable in the admin. Register it in a plugin and it persists no matter what theme is active. The same logic covers reusable tools you carry between projects and integrations with third-party services.

When you genuinely can’t decide, default to a plugin. A plugin is portable, and the cost of putting something in a plugin that could have lived in functions.php is small. The cost of the reverse, a client losing access to their content, is not.

Chapter 16 builds that plugin.

Commit your work

You’ve expanded functions.php into an organized inc/ structure with hooks, filters, image sizes, pagination, and helper functions. Commit it.

References