How to Use WordPress: A Developer’s Guide to Classic Themes

in WordPress | Updated

Table of Contents

Most “how to use WordPress” guides treat the platform as if it were one thing. It isn’t. Since WordPress 5.9 shipped Full Site Editing in 2022, there have effectively been two WordPresses running side by side: the classic one, built on PHP templates, functions.php, metaboxes, shortcodes, and the Customizer; and the block one, built on theme.json, block templates, and the Site Editor.

This guide is for the first one. Classic themes aren’t legacy, they aren’t deprecated, and they aren’t going anywhere. They still power the overwhelming majority of commercial WordPress sites we build and maintain at getButterfly — and if you’re an old-school developer who prefers hand-coded templates, custom post types with metaboxes, and a wp-content/themes/your-theme/ folder you actually understand line by line, this is the workflow we’d still recommend in 2026. Our own plugin portfolio is built entirely on the classic APIs described below.

Why classic themes still matter in 2026

The Site Editor is genuinely impressive for content-first sites where non-technical editors need to rearrange layouts without touching code. But it makes a trade-off that doesn’t suit every project: it moves design decisions from version-controlled files into the database. For agencies, long-lived client sites, and developer-led projects, that trade-off is often backwards.

Here’s where classic themes still win cleanly:

  • Everything lives in code. Your templates, styles, and logic are files on disk. Git tracks every change. Staging and production stay in sync because you deploy files, not database rows.
  • Total control over markup. When you write single.php by hand, the HTML is exactly what you wrote. No block wrappers, no auto-generated class names, no surprises.
  • Metaboxes are a first-class citizen. Custom post types with rich, structured metadata — the backbone of any real CMS work — are still easier to build and maintain with classic metaboxes than with block editor sidebars.
  • Shortcodes still work everywhere. Inside blocks, in widgets, in PHP, in third-party page builders. They’re the most portable content primitive WordPress has ever shipped.
  • Performance is predictable. No block JSON parsing, no theme.json cascade, no editor-side JavaScript bundle to load. Classic themes can be extremely fast by default.

None of this means you should avoid blocks. We’ll come back to when the Site Editor is the right call. But if the tooling you already know — PHP, the template hierarchy, wp_enqueue_scripts, add_meta_box(), register_setting() — is the tooling you want to keep using, the classic workflow is still fully supported, fully documented, and still the path we reach for first on most client work.

Anatomy of a classic theme

A classic theme is, at minimum, two files in wp-content/themes/your-theme/: style.css with a header comment that identifies the theme, and index.php as the universal fallback template. Everything beyond that is optional — but a real theme quickly grows into something like this:

your-theme/
├── style.css          # Theme header + compiled CSS
├── functions.php      # Hooks, enqueues, theme setup
├── index.php          # Universal fallback
├── header.php         # Opening <html> through <header>
├── footer.php         # <footer> through closing </html>
├── sidebar.php        # Widget areas
├── front-page.php     # Homepage
├── home.php           # Blog index
├── single.php         # Single post
├── page.php           # Single page
├── archive.php        # Category, tag, date archives
├── search.php         # Search results
├── 404.php            # Not found
├── template-parts/    # Reusable partials
│   ├── content.php
│   └── content-none.php
├── inc/               # PHP includes (metaboxes, CPTs, etc.)
└── assets/            # CSS, JS, images

The magic is the template hierarchy. WordPress looks at the current request and walks a prioritised list of filenames until it finds one that exists. Request a single post of type product, and WordPress will try single-product-{slug}.php, then single-product.php, then single.php, then singular.php, then index.php. You get as specific as you need, as general as you want, and the framework does the routing for you.

A minimal style.css header is all it takes to register a theme:

/*
Theme Name: Your Theme
Theme URI: https://example.com/
Author: Your Name
Author URI: https://example.com/
Description: A classic, hand-coded WordPress theme.
Version: 1.0.0
Requires at least: 6.0
Requires PHP: 8.0
License: GPL v2 or later
Text Domain: your-theme
*/

And a functions.php that sets up the basics — theme support flags, a menu location, and a stylesheet enqueue — looks like this:

<?php
add_action( 'after_setup_theme', function () {
    add_theme_support( 'title-tag' );
    add_theme_support( 'post-thumbnails' );
    add_theme_support( 'html5', [ 'search-form', 'comment-form', 'gallery', 'caption' ] );
    add_theme_support( 'responsive-embeds' );
    add_theme_support( 'editor-styles' );

    register_nav_menus( [
        'primary' => __( 'Primary Menu', 'your-theme' ),
        'footer'  => __( 'Footer Menu', 'your-theme' ),
    ] );
} );

add_action( 'wp_enqueue_scripts', function () {
    wp_enqueue_style(
        'your-theme-style',
        get_stylesheet_uri(),
        [],
        wp_get_theme()->get( 'Version' )
    );
} );

That’s a working theme. From here, everything you add — custom post types, metaboxes, shortcodes, settings screens, REST endpoints — is an extension of the same pattern: hook into WordPress, register something, render it.

Shortcodes: the most portable content primitive WordPress ever shipped

Shortcodes were introduced in WordPress 2.5, in 2008. They still work. They work inside the block editor (via the Shortcode block), inside classic editors, inside widgets, inside theme templates via do_shortcode(), and inside almost every page builder on the market. Nothing else in the WordPress API has that reach.

We register a shortcode with a callback that returns (never echoes) a string:

<?php
add_shortcode( 'button', function ( $atts, $content = '' ) {
    $atts = shortcode_atts( [
        'url'    => '#',
        'style'  => 'primary',
        'target' => '_self',
    ], $atts, 'button' );

    return sprintf(
        '<a href="%1$s" target="%2$s" class="btn btn-%3$s" rel="noopener">%4$s</a>',
        esc_url( $atts['url'] ),
        esc_attr( $atts['target'] ),
        esc_attr( $atts['style'] ),
        wp_kses_post( $content )
    );
} );

Used in content like this:

[button url="/contact/" style="primary"]Get in touch[/button]

A few lessons we’ve learned shipping shortcodes in plugins over the years:

  • Always return, never echo. Echoing from a shortcode inserts output above the content and corrupts the page. This is the single most common shortcode bug.
  • Escape every attribute. Shortcode attributes come straight from the database and should be treated as untrusted. esc_url()esc_attr()esc_html()wp_kses_post() — pick the right one for the context.
  • Namespace your tags. [button] is asking for a collision. [gb_button] isn’t. Use a short prefix you control.
  • Keep the rendering fast. A shortcode that runs a WP_Query on every page load will bite you at scale. Cache with transients when the output doesn’t change per-request.

Custom post types and metaboxes

This is where classic WordPress earns its keep. A custom post type with a handful of metaboxes gives you a structured content model with an admin UI, permissions, REST API exposure, and archive pages — for maybe 80 lines of PHP. There is no faster way to build a small CMS.

A typical registration, kept in inc/cpt-project.php and required from functions.php:

<?php
add_action( 'init', function () {
    register_post_type( 'project', [
        'labels' => [
            'name'          => __( 'Projects', 'your-theme' ),
            'singular_name' => __( 'Project', 'your-theme' ),
            'add_new_item'  => __( 'Add New Project', 'your-theme' ),
            'edit_item'     => __( 'Edit Project', 'your-theme' ),
        ],
        'public'       => true,
        'has_archive'  => true,
        'menu_icon'    => 'dashicons-portfolio',
        'supports'     => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
        'show_in_rest' => true,
        'rewrite'      => [ 'slug' => 'projects' ],
    ] );
} );

Now for metaboxes. You have two choices in 2026, and both are valid. The modern choice is register_post_meta(), which exposes the field over REST and makes it available to the block editor sidebar. The classic choice is add_meta_box() with a custom-rendered HTML panel, which gives you complete control over the UI. For rich, interlinked fields — client dropdowns, repeater rows, conditional visibility — the classic approach is still faster to build and easier to debug.

<?php
add_action( 'add_meta_boxes', function () {
    add_meta_box(
        'project_details',
        __( 'Project Details', 'your-theme' ),
        'your_theme_render_project_details',
        'project',
        'normal',
        'high'
    );
} );

function your_theme_render_project_details( $post ) {
    wp_nonce_field( 'save_project_details', 'project_details_nonce' );

    $client = get_post_meta( $post->ID, '_project_client', true );
    $url    = get_post_meta( $post->ID, '_project_url', true );
    $year   = get_post_meta( $post->ID, '_project_year', true );
    ?>
    <p>
        <label for="project_client"><?php esc_html_e( 'Client', 'your-theme' ); ?></label><br>
        <input type="text" id="project_client" name="project_client"
               value="<?php echo esc_attr( $client ); ?>" class="widefat">
    </p>
    <p>
        <label for="project_url"><?php esc_html_e( 'Live URL', 'your-theme' ); ?></label><br>
        <input type="url" id="project_url" name="project_url"
               value="<?php echo esc_url( $url ); ?>" class="widefat">
    </p>
    <p>
        <label for="project_year"><?php esc_html_e( 'Year', 'your-theme' ); ?></label><br>
        <input type="number" id="project_year" name="project_year"
               value="<?php echo esc_attr( $year ); ?>" min="1990" max="2100">
    </p>
    <?php
}

add_action( 'save_post_project', function ( $post_id ) {
    if ( ! isset( $_POST['project_details_nonce'] )
        || ! wp_verify_nonce( $_POST['project_details_nonce'], 'save_project_details' ) ) {
        return;
    }
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    $fields = [
        '_project_client' => [ 'sanitize_text_field', 'project_client' ],
        '_project_url'    => [ 'esc_url_raw',         'project_url'    ],
        '_project_year'   => [ 'absint',              'project_year'   ],
    ];

    foreach ( $fields as $meta_key => [ $sanitiser, $field ] ) {
        if ( isset( $_POST[ $field ] ) ) {
            update_post_meta( $post_id, $meta_key, call_user_func( $sanitiser, $_POST[ $field ] ) );
        }
    }
} );

Three non-obvious things in that save handler we’ve been burned by over the years: always verify a nonce, always bail on autosave (otherwise you’ll overwrite a user’s in-progress edits with empty fields), and always pick a sanitiser that matches the data type. sanitize_text_field() on a URL strips nothing useful; esc_url_raw() on a number silently returns an empty string for anything with a leading zero. Match the tool to the job.

Settings pages with the Settings API

If your theme or plugin needs options — an API key, a default layout, a feature toggle — the Settings API is the boring, correct way to build the screen. It handles the form rendering, nonce generation, capability checks, and the options.php POST handler. You write the field callbacks and a sanitisation function. That’s it.

<?php
add_action( 'admin_menu', function () {
    add_options_page(
        __( 'Your Theme Settings', 'your-theme' ),
        __( 'Your Theme', 'your-theme' ),
        'manage_options',
        'your-theme-settings',
        'your_theme_render_settings_page'
    );
} );

add_action( 'admin_init', function () {
    register_setting( 'your_theme_settings', 'your_theme_options', [
        'type'              => 'array',
        'sanitize_callback' => 'your_theme_sanitize_options',
        'default'           => [ 'api_key' => '', 'enable_analytics' => 0 ],
    ] );

    add_settings_section(
        'your_theme_general',
        __( 'General', 'your-theme' ),
        '__return_false',
        'your-theme-settings'
    );

    add_settings_field(
        'api_key',
        __( 'API Key', 'your-theme' ),
        'your_theme_field_api_key',
        'your-theme-settings',
        'your_theme_general'
    );

    add_settings_field(
        'enable_analytics',
        __( 'Enable Analytics', 'your-theme' ),
        'your_theme_field_enable_analytics',
        'your-theme-settings',
        'your_theme_general'
    );
} );

function your_theme_field_api_key() {
    $options = get_option( 'your_theme_options' );
    printf(
        '<input type="text" name="your_theme_options[api_key]" value="%s" class="regular-text">',
        esc_attr( $options['api_key'] ?? '' )
    );
}

function your_theme_field_enable_analytics() {
    $options = get_option( 'your_theme_options' );
    printf(
        '<label><input type="checkbox" name="your_theme_options[enable_analytics]" value="1" %s> %s</label>',
        checked( 1, $options['enable_analytics'] ?? 0, false ),
        esc_html__( 'Load analytics script on the frontend', 'your-theme' )
    );
}

function your_theme_sanitize_options( $input ) {
    return [
        'api_key'          => sanitize_text_field( $input['api_key'] ?? '' ),
        'enable_analytics' => ! empty( $input['enable_analytics'] ) ? 1 : 0,
    ];
}

function your_theme_render_settings_page() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <form method="post" action="options.php">
            <?php
            settings_fields( 'your_theme_settings' );
            do_settings_sections( 'your-theme-settings' );
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

A rule we’ve held to for years: store related options in a single array, not as separate wp_options rows. A plugin with thirty options should add one row to wp_options, not thirty. Every autoloaded option is loaded on every request. Stack enough of them and you’ll watch your Time to First Byte climb on sites you haven’t touched in months.

The Customizer: still useful, never deprecated

The WordPress Customizer (customize.php) has been quietly in the background since 2012. It never went away. For classic themes it’s still the right place to expose theme-level options that an editor might want to preview live — a logo upload, a primary colour, a footer copyright line, a social links list.

<?php
add_action( 'customize_register', function ( $wp_customize ) {
    $wp_customize->add_section( 'your_theme_branding', [
        'title'    => __( 'Branding', 'your-theme' ),
        'priority' => 30,
    ] );

    $wp_customize->add_setting( 'your_theme_accent_color', [
        'default'           => '#0073aa',
        'sanitize_callback' => 'sanitize_hex_color',
        'transport'         => 'postMessage',
    ] );

    $wp_customize->add_control( new WP_Customize_Color_Control(
        $wp_customize,
        'your_theme_accent_color',
        [
            'label'   => __( 'Accent Color', 'your-theme' ),
            'section' => 'your_theme_branding',
        ]
    ) );

    $wp_customize->add_setting( 'your_theme_footer_text', [
        'default'           => '',
        'sanitize_callback' => 'wp_kses_post',
    ] );

    $wp_customize->add_control( 'your_theme_footer_text', [
        'label'   => __( 'Footer Text', 'your-theme' ),
        'section' => 'your_theme_branding',
        'type'    => 'textarea',
    ] );
} );

add_action( 'wp_head', function () {
    $color = get_theme_mod( 'your_theme_accent_color', '#0073aa' );
    printf(
        '<style>:root { --accent: %s; }</style>',
        esc_attr( $color )
    );
} );

A small but important detail: 'transport' => 'postMessage' tells the Customizer to update the preview via JavaScript instead of a full page reload. Pair it with a small preview script and your colour picker feels instant. With 'refresh' (the default) the preview reloads the whole page on every keystroke, which is fine but feels sluggish for simple values.

Writing the templates: a real single.php

With the pieces above in place, a single-project.php template that actually uses the custom post type and its metadata writes itself:

<?php get_header(); ?>

<main id="primary" class="site-main">
    <?php while ( have_posts() ) : the_post(); ?>

        <article id="post-<?php the_ID(); ?>" <?php post_class( 'project-single' ); ?>>

            <header class="project-header">
                <h1 class="project-title"><?php the_title(); ?></h1>

                <?php
                $client = get_post_meta( get_the_ID(), '_project_client', true );
                $url    = get_post_meta( get_the_ID(), '_project_url', true );
                $year   = get_post_meta( get_the_ID(), '_project_year', true );
                ?>

                <dl class="project-meta">
                    <?php if ( $client ) : ?>
                        <dt><?php esc_html_e( 'Client', 'your-theme' ); ?></dt>
                        <dd><?php echo esc_html( $client ); ?></dd>
                    <?php endif; ?>

                    <?php if ( $year ) : ?>
                        <dt><?php esc_html_e( 'Year', 'your-theme' ); ?></dt>
                        <dd><?php echo esc_html( $year ); ?></dd>
                    <?php endif; ?>

                    <?php if ( $url ) : ?>
                        <dt><?php esc_html_e( 'Live site', 'your-theme' ); ?></dt>
                        <dd><a href="<?php echo esc_url( $url ); ?>" rel="noopener"><?php echo esc_html( $url ); ?></a></dd>
                    <?php endif; ?>
                </dl>
            </header>

            <?php if ( has_post_thumbnail() ) : ?>
                <figure class="project-thumbnail">
                    <?php the_post_thumbnail( 'large' ); ?>
                </figure>
            <?php endif; ?>

            <div class="project-content">
                <?php the_content(); ?>
            </div>

        </article>

    <?php endwhile; ?>
</main>

<?php get_footer(); ?>

Nothing clever. Nothing auto-generated. Every tag in the rendered HTML is one you put there. When a designer hands you a comp and asks for pixel-accurate output, this is the workflow that gets you there fastest.

Hybrid themes: keep your PHP, gain block styling

You don’t have to choose between the classic world and the block world as an all-or-nothing commitment. A hybrid theme is a classic PHP-templated theme that opts in to select block features through theme.json. You keep header.php, single.php, functions.php — everything — and you add a single file that teaches the block editor about your palette, typography, and spacing.

A small theme.json that gives editors a constrained set of choices in the content area, while your templates stay in PHP:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "color": {
      "palette": [
        { "slug": "accent",  "name": "Accent",  "color": "#0073aa" },
        { "slug": "ink",     "name": "Ink",     "color": "#111111" },
        { "slug": "paper",   "name": "Paper",   "color": "#ffffff" }
      ],
      "custom": false,
      "customGradient": false
    },
    "typography": {
      "fontSizes": [
        { "slug": "small",  "name": "Small",  "size": "0.875rem" },
        { "slug": "medium", "name": "Medium", "size": "1rem"     },
        { "slug": "large",  "name": "Large",  "size": "1.25rem"  }
      ],
      "customFontSize": false
    },
    "spacing": {
      "units": [ "px", "rem", "em", "%" ]
    },
    "layout": {
      "contentSize": "680px",
      "wideSize":    "1100px"
    }
  }
}

This is the configuration we reach for on most client projects now. Editors get a controlled colour palette and type scale inside the block editor — no accidental hot pink headings — and the theme itself is still a classic PHP theme we can reason about, version, and deploy like any other codebase. If you want to go one step further and ship a handful of custom blocks without an npm build, our guide to PHP-only block registration in WordPress covers the pattern we use across our own plugins, with a worked example in building a call-to-action block.

What we’ve learned building WordPress plugins for 15 years

Our own work at getButterfly is built entirely on WordPress plugin development — from our Active Analytics dashboard and the Lighthouse performance auditor to lighter-weight tools like YouTube Playlist Player and Admin Menu Tree Page View. A few opinions we’ve formed from shipping and maintaining plugins across more WordPress versions than we’d like to count:

  • The database is the hardest part to change. Table schemas, option names, meta keys — once they’re in production you’re stuck with them. Name them carefully the first time. Prefix everything. Version your schema.
  • Hooks are a contract. If you add a do_action() or apply_filters() to your plugin, someone will hook into it. Renaming or removing it is a breaking change, even if no documentation promised otherwise.
  • Autoloaded options are a tax. Every row in wp_options with autoload=yes is loaded on every single request. Audit this quarterly on any site you’ve had for more than a year. You’ll find abandoned plugins still paying rent.
  • Transients aren’t forever. Object cache plugins happily evict transients under memory pressure, and the database fallback gets cleaned by WP-Cron. Don’t use them as durable storage; use them exactly as the name says — transient caches.
  • Test against the oldest PHP and WordPress versions you support. Not because anyone should still be running PHP 7.4, but because plenty of people are, and the bug report will find you either way.

When to pick the Site Editor instead

Everything above is a case for classic themes. Honesty requires the other side of the ledger too. We’d reach for a full block theme and the Site Editor when:

  • The site is content-first, the editors are non-technical, and layout flexibility matters more to them than version control matters to us.
  • The design is simple enough that the block editor’s constraints are features, not limits.
  • The team has no PHP developer long-term, and a theme the client can edit themselves is worth more than a theme we can extend precisely.
  • The project is a personal site or a small blog where the Site Editor’s live-editing workflow is genuinely pleasant.

For everything else — agency work, long-lived client sites, plugins, anything with a custom data model — the classic workflow is what we reach for first, and it’s what we’d recommend to any developer who values a codebase they own end-to-end. You’ll find more of our practical write-ups in the WordPress category on this site.

FAQs

Are classic themes deprecated?

No. Classic themes are fully supported in WordPress 6.x and there are no announced plans to deprecate them. The template hierarchy, functions.php, the Customizer, shortcodes, metaboxes, and the Settings API are all still first-class parts of WordPress.

Can I use the block editor with a classic theme?

Yes. The block editor (Gutenberg) is the default post editor regardless of your theme type. Classic themes render block content through the_content() exactly like any other content. If you want blocks to pick up your theme’s colours and typography, add a minimal theme.json as shown above.

Should I convert an existing classic theme to a block theme?

Almost never, unless you have a specific editor-workflow reason to. A full conversion is effectively a rewrite, and it throws away years of tested template code for a workflow that may not suit the site. Adding a theme.json to a classic theme gives you most of the styling benefits without the rewrite.

Do shortcodes still work inside the block editor?

Yes, through the built-in Shortcode block. They also render inside Classic blocks, inside Custom HTML blocks that pass through do_shortcode() in a template, and anywhere else the_content filter runs.

What’s the difference between get_option() and get_theme_mod()?

get_option() reads from wp_options and is theme-independent — the value survives a theme switch. get_theme_mod() reads theme-specific modifications stored under the theme’s name, and those are wiped (or rather, orphaned) when you switch themes. Use theme_mod for values that only make sense for the current theme (a logo, a colour); use option for anything the site itself owns.

The WordPress ecosystem has two strong traditions now, and they’re both valid. The Site Editor is where the project’s design energy is going; classic themes are where most of the world’s working WordPress code already lives, and where serious developer-led projects still start. If you came up writing PHP, if you like owning your markup, if your projects have custom data models with real structure behind them — the classic workflow is still the one we’d bet on. Hopefully the snippets above give you a clean starting point the next time you open wp-content/themes/ and begin a new style.css.

Related Posts