functions.php
The theme's bootstrap file, loaded on every request — what belongs in it, what belongs in a plugin, and why prefixing matters.
wp-content/themes/{theme}/functions.php — included on every request, child before parentfunctions.php is the theme's bootstrap. It is not a template: WordPress includes it on every front-end, admin, AJAX and REST request, after the theme is determined but before init. When a child theme is active, the child's functions.php is loaded first, then the parent's — both run.
What belongs here
Presentation concerns that should disappear when the theme is switched: add_theme_support() calls, asset enqueues, register_nav_menus(), register_sidebar(), image sizes, and the theme's own template helpers.
What belongs in a plugin
Anything the site would still need after a redesign: custom post types and taxonomies, shortcodes, analytics snippets, REST routes, custom tables. Registering a post type in functions.php means switching theme orphans every one of those posts.
Prefixing
There is no autoloader and no namespace by default. Every function you declare lands in the global namespace alongside core, every plugin and the parent theme. Prefix consistently (mytheme_) or wrap the file in a namespace or a class.
function_exists guards
Because the child loads first, a parent theme that wraps its functions in if ( ! function_exists( ... ) ) lets a child replace them wholesale — the same pluggable pattern core uses. Guard functions in a parent theme; in a child theme the guard is pointless.
Example
<?php
/**
* Theme bootstrap.
*
* @package MyTheme
*/
defined( 'ABSPATH' ) || exit; // stop direct HTTP access to this file
/**
* Theme setup.
*
* after_setup_theme fires before init, which is early enough for every
* add_theme_support() feature. Registering these on init is too late.
*/
function mytheme_setup() {
add_theme_support( 'title-tag' );
add_theme_support( 'post-thumbnails' );
add_theme_support( 'responsive-embeds' );
register_nav_menus(
array(
'primary' => __( 'Primary menu', 'mytheme' ),
)
);
// Text domain must match the Text Domain header in style.css.
load_theme_textdomain( 'mytheme', get_template_directory() . '/languages' );
}
add_action( 'after_setup_theme', 'mytheme_setup' );
/**
* Pluggable. A child theme's functions.php runs BEFORE this file, so if the
* child declares mytheme_excerpt_length() first, this definition is skipped.
* Only useful in a parent theme.
*/
if ( ! function_exists( 'mytheme_excerpt_length' ) ) {
function mytheme_excerpt_length( $length ) {
return 30;
}
}
add_filter( 'excerpt_length', 'mytheme_excerpt_length' );
// Split large themes into includes rather than one 2,000-line file.
require_once get_template_directory() . '/inc/template-tags.php';
// No closing PHP tag.When to use it
- Deciding whether a piece of client customisation goes in the theme or a plugin
- Making a parent theme's helper functions overridable from a child theme
- Tracking down a 'Cannot modify header information' warning after editing a theme
- Splitting a sprawling functions.php into scoped include files
More examples
Prefixes and their namespaced longhand
All three register the same filter. Namespacing removes the prefix collision risk but every add_action/add_filter string must be fully qualified, and a closure cannot be removed later with remove_filter() because there is no name to pass. Use closures only for behaviour nobody should be able to unhook.
<?php
// Longhand: global namespace, manual prefix on every symbol.
function mytheme_excerpt_more( $more ) {
return ' …';
}
add_filter( 'excerpt_more', 'mytheme_excerpt_more' );
// Or a closure, when nothing else needs to reference the callback.
add_filter( 'excerpt_more', static function ( $more ) {
return ' …';
} );
/*
* The namespaced form has to live in its own file. PHP requires a namespace
* declaration to be the first statement in the script, so pasting it below
* the code above is a fatal parse error, not a style choice.
*
* inc/content.php:
*
* <?php
* namespace MyTheme\Content;
*
* function excerpt_more( $more ) {
* return ' …';
* }
*
* // The callback string must now be fully qualified — 'excerpt_more'
* // alone would be looked up in the global namespace and never found.
* add_filter( 'excerpt_more', __NAMESPACE__ . '\\excerpt_more' );
*/The same code, but in a plugin
The test is simple: if the feature should still work after a redesign, it is a plugin. Post types, taxonomies, shortcodes, tracking codes and REST endpoints all fail that test in functions.php. A single-file mu-plugin is a reasonable home for site-specific glue.
<?php
/**
* Plugin Name: Site Content Types
*
* A custom post type registered here survives a theme switch. The same
* register_post_type() call in functions.php orphans every 'event' post
* the moment the client changes theme.
*/
add_action( 'init', function () {
register_post_type(
'event',
array(
'label' => __( 'Events', 'site-content' ),
'public' => true,
'has_archive' => true,
'show_in_rest' => true, // required for the block editor
'supports' => array( 'title', 'editor', 'thumbnail' ),
)
);
} );Loading admin-only code conditionally
is_admin() only tells you the request targets wp-admin — it is not a capability check and never a security boundary. Use it to skip loading files, and current_user_can() to decide what someone is allowed to do.
<?php
// functions.php runs on every request including AJAX and REST, so gate
// anything expensive rather than parsing it unconditionally.
if ( is_admin() ) {
require_once get_template_directory() . '/inc/admin-columns.php';
}
// is_admin() is true for admin-ajax.php too. To exclude AJAX:
if ( is_admin() && ! wp_doing_ajax() ) {
require_once get_template_directory() . '/inc/meta-boxes.php';
}
Discussion