Modern WordPress Theme Development With Sage 9

Theme Development with Sage 9

You’ll probably agree with me that as a WordPress developer sometimes you can feel left out. All the cool kids use JAM stack and you’re stuck using last decade’s tech.

But WordPress is not going anywhere anytime soon. And with Gutenberg release, WordPress market dominance is likely to stay here for a while.

So our best bet as web developers to modernize WordPress development workflow as much as possible and make it enjoyable. As the project lead for Sage – Craig have said:

WordPress is a lemon, but the industry likes WordPress, so let’s make lemonade.

I’m going to introduce you to a modern, maintainable and joyful WordPress development using Sage 9 starter theme by Roots.

Here is a bit info about my background with Roots and Sage.

I’ve used Sage 9 as a freelance developer for 10 different projects since it’s release in February 2018. Also, I’m an author on as well as a patron and advocate for Roots.

This article doesn’t cover WordPress development basics. If you’re reading this I expect that you have some experience developing themes for clients using Underscores, The Genesis Framework or some personal setup.

I assume that you know the following things:

#What Is Sage?

There was a joke on Roots Slack that should change their headline to:

A bunch of tools that make WordPress suck a bit less.

But jokes aside…

Sage is an open-source starter theme for WordPress with large and active community support behind it. Sage is meant to be used as a starting point on a new project so it doesn’t impose a certain look or style on a theme. You can make your theme whatever you want it to be. Sage can be used for small sites as well as for enterprise level sites. It is powering websites for brands like JetBlue, UPS, WebMD.

#Why Sage?

In the words of Sage creators:

If Underscores is a “1,000 hour head start”, Sage is a 10,000 hour head start.

But what I like the most about Sage is the community. Roots Discourse is a valuable resource with tons of topics about how to write better code. The forum is very active and friendly so you can always count to get help when you find yourself stuck. Although it’s oriented at discussions relating Sage, you’ll inevitably touch other WordPress topics so it’s a great place to be for any WordPress developer.

#What You’ll Find In Sage

  • DRY (Don’t Repeat Yourself) templates with template inheritance using Laravel’s Blade templating engine.
  • An improved project structure and better template file organization.
  • Modern PHP along with PSR-2 coding standards – the most widely used and accepted coding standards in the PHP community.
  • A modern build process, assets optimization, browser-sync, and HMR (Hot Module Replacement) with webpack.
  • Dependency management with Composer and npm.

Sage makes it a delight to maintain or collaborate on projects with your colleagues because everyone is on the same page when following the best practices of Sage.

Once I tried building a theme with Sage I switched to working exclusively with this starter theme and never looked back. Now I use Sage for all of my freelancing projects and that actually helped me to find more great clients than ever who value quality and modern standards.

Sage has a bit of learning curve but don’t worry it’s not that bad and I’ll cover main things in this article and you can always fill the gaps on Roots Discourse. So let’s get started!

#Sage Setup

First, make sure you have installed all dependencies before moving on.

I’ll assume that you have some experience developing WordPress themes and you already have local WordPress environment setup like MAMP.

Then all that’s left to do is install the following:

Take care of those and let’s move on.

Now we can use Composer to install Sage.

From the wp-content/themes/ directory in your WordPress install run this command:

composer create-project roots/sage replace-with-your-theme-name

Confirm with “Yes” to remove the existing git history.

After theme installation, first, you’ll be prompted to update style.css theme headers.

Next, you’ll be asked for the URL of your local development site and the path to your theme directory. Make sure path reflects your local and hosting environment when you’ll deploy. However, local development URL is only relevant for webpack so it has nothing to do with an actual website domain.

Sage config in CLI

To finalize select a CSS framework. You’ll see that Sage has a wide selection of frameworks. Choose between Bootstraps, Foundation, Bulma, Tachyons, and my new favorite Tailwind. Or you can just select none to start from scratch.

For Windows users, this theme setup routine is skipped and by default Bootstrap framework is selected.

You can work around it by running these commands from within your theme directory:

vendor\bin\sage meta
vendor\bin\sage config
vendor\bin\sage preset

Next run yarn to install npm dependencies.

Finally, run yarn build to compile the files in the resources/assets/ directory for the first time.

Once webpack is done, you’ll see dist/ directory with all of the compiled assets. Don’t ever touch the contents of the dist/ directory, because they’re meant for production and will be overwritten on a next yarn build. Always make sure to edit the source files inside the resources/assets/ directory.

Now we’re ready to start working on our theme. Run yarn start to watch for file changes and start Browsersync.

#Theme Structure And Functionality

Sage theme folder structure
Sage theme folder structure

All the Sage functionality is kept inside app/ folder. You can place any additional code in one of these four files:


The main file you’ll work with is setup.php. Treat it like your new functions.php. Sage uses it to enqueue assets, register navigation menus, sidebars, and add core functionality.

Use filter.php to customize and extend your theme.

admin.php is for WordPress backend panel changes.

And you’re left with helpers.php which you can use to create helper functions like this:

 * This little function will return the contents of already optimized assets
 * Very useful for inlining SVG files in templates but keeping them clean.
 * @param string i.e: "images/icon.svg".
function get_file_contents($asset)
    $asset_url = asset_path($asset);

    if (fopen($asset_url, 'r')) {
        return file_get_contents($asset_url);
    } else {
        return 'Could not locate the file. Make sure it exists! Or try running "yarn build" again';

When you feel that none of these files fit for your code you can create a new one inside the app/ folder. You’ll also need to load it from inside resources/functions.php. Following is the code where you can customize the array at the end of array_map function – to include an extra file or two.

 * Sage required files
 * The mapped array determines the code library included in your theme.
 * Add or remove files to the array as needed. Supports child theme overrides.
array_map(function ($file) use ($sage_error)
    $file = "../app/{$file}.php";
    if (!locate_template($file, true, true)) {
        $sage_error(sprintf(__('Error locating <code>%s for inclusion.', 'sage'), $file), 'File not found');
}, ['helpers', 'setup', 'filters', 'admin']);

Other than that you shouldn’t touch anything else inside functions.php and use setup.php instead.

#Modern PHP

There are a few things going on inside Sage .php files that you probably already noticed. Sage developers are trying to instill the philosophy of thinking outside of WordPress bubble. That’s why Sage uses PSR-2 coding standards – the most widely used guidelines in the PHP community – instead of WordPress Coding Standards.

You’ll see features like short array syntax using brackets [] instead of array() and anonymous functions.

But the most important change is namespaces. Sage uses App namespace at the top of .php files

Namespaces help you to avoid name collision so you don’t have to prefix your function names.

There are a few caveats with namespaces that you’ll want to keep in mind. First, when you want to call a class that is defined outside of Sage you’ll have to step out of namespace. Here is a common example with WP_Query.

$query = new \WP_Query();

Also, if you’re not a fan of anonymous functions and want to call a named function you’ll need to use __NAMESPACE__ constant. Here is an example:

add_filter('excerpt_more', __NAMESPACE__ . '\\custom_excerpt');

Notice the double backslashes in the string that we are passing because we need to escape the backslash.

Here is a bit more reading on namespaces and upping PHP requirements on WordPress.

If you’re using VS Code editor there is a super helpful extension called PHP CS Fixer. It can format your PHP code according to PSR-2 coding guidelines. It even converts an array() syntax to short brackets syntax [].

#Loading Additional JS/CSS Files And JS Routes

You can write your JavaScript inside scripts/routes/common.js. Sage uses Routes to run only the code that’s meant for that specific template. However, all your code will still be compiled in a single main.js file.

For performance reasons, you may want to split your JavaScript files and enqueue them conditionally on certain templates.

You can manage front-end assets output inside resources/assets/config.json.

"entry": {
    "main": [
    "customizer": [
    "prism": [

Here we’re creating a new entry called “prism” which later we’ll use for a handle name. Also, we’re setting a path related to this config.json.

Now let’s enqueue the new file in app/setup.php only for single posts.

  * Theme assets
 add_action('wp_enqueue_scripts', function () {
     wp_enqueue_style('sage/main.css', asset_path('styles/main.css'), false, null);
     wp_enqueue_script('sage/main.js', asset_path('scripts/main.js'), ['jquery'], null, true);

     if (is_single()) {
        wp_enqueue_style('sage/prism.css', asset_path('styles/prism.css'), false, null);
        wp_enqueue_script('sage/prism.js', asset_path('scripts/prism.js'), [], null, true);
 }, 100);

Finally, don’t forget to run yarn build from your theme directory.

#Blade Templates

Laravel’s Blade templates are probably the best thing about Sage. Blade uses an elegant and concise syntax that makes templates more readable and a joy to write.

Here is an example of how single.php template looks in a standard WordPress theme and in Sage.

Standard WordPress template:

<?php get_header(); ?>

  <div id="primary" class="content-area">
    <main id="main" class="site-main" role="main">

      while (have_posts()) : the_post();
        get_template_part('template-parts/content', 'page');
        // If comments are open or we have at least one comment, load up the comment template.
        if (comments_open() || get_comments_number()) :
      endwhile; // End of the loop.



Blade template:


  @while(have_posts()) @php the_post() @endphp

With Blade we don’t have to keep repeating get_header(), get_sidebar(), get_footer() in every template and instead we can use Blade’s template inheritance to create a layout. Then we can extend that layout like we’re doing at the very top. Everything that we put within @section will be wrapped by the code inside

Here is how default Sage layout looks like:

<!doctype html>
<html {!! get_language_attributes() !!}>
  <body @php body_class() @endphp>
    @php do_action('get_header') @endphp
    <div class="wrap container" role="document">
      <div class="content">
        <main class="main">
        @if (App\display_sidebar())
          <aside class="sidebar">
    @php do_action('get_footer') @endphp
    @php wp_footer() @endphp

So again, everything that we put inside @section('content') in the single template will be rendered in place of @yield('content').

#Blade Syntax

Blade uses a simple syntax for working with PHP’s loops and conditionals. Just prepend @ and write code with regular PHP.

A few things to note. There is no opening and closing of PHP tags and no : or ; at the end of the line when using Blade directives. If you want to write regular PHP code then you can open a PHP tag with @php and close with @endphp. However, generally, you’ll want to avoid that as Sage encourages to stick to MVC architectural pattern, separating logic from the views. We’ll use a Controller for our PHP logic, but more on that later.

If you want to quickly echo a variable then Blade provides two options:

First, with double curly braces:

{{ $var }}

The code inside is automatically sent through PHP’s htmlspecialchars function and converts special characters to HTML entities to prevent XSS attacks. So this is what you should use in most cases unless you have a good reason not to, like echoing get_the_content() within a post.

In cases, where you don’t want your data to be escaped, you can use the following syntax:

{!! get_the_content() !!}

#Creating New Templates

Sage uses the default WordPress template hierarchy so the naming convention is the same except instead of .php Blade uses .blade.php file extension. If you need a refresher here is a handy visualization of WordPress template hierarchy.

To keep templates clean, maintainable and DRY we can use Blade’s @include directive. Basically, it loads a template partial and all variables that are available to the parent template will be made available to the included template.

Even though the included template partial will inherit all data available to the parent template, you may also pass an array of extra data.

Here is a scenario where it becomes useful. Let’s say we have a template for an image.

@include('partials.image', ['id' => $img_id])

Inside a partial you can set a default value.

$id = $id ?? get_post_thumbnail_id();

So when we’re including a partial we can pass the id or if we don’t then our default value get_post_thumbnail_id() will be used.

What I like to do personally is abstracting everything into partials and leaving a template as a collection of partials. But of course, you can use what makes sense for your project. The idea is to keep templates DRY so when you have to change something you can do it in one place instead of going to look for each instance.


If you’re used to creating a $counter variable in your loops to set conditionals for the output then you won’t have to do that anymore within Blade.

When using Blade loop directives you’ll have access to $loop variable. You can get useful information such as a current loop iteration or if it’s a first or last loop.

Here is how it would look:

@foreach ($items as $item)
    @if ($loop->first)
        This is the first iteration.

You can find all $loop properties in Blade’s documentation.

#Custom Directives

Blade uses directives like @foreach and @if which get compiled to regular PHP by Blade’s templating engine. So you can actually create your own custom directives.

Sage by default has one custom directive called @asset. We’re going to use it whenever we want to get an asset path. For example:

<img src="@asset('images/icon.svg')">

The Blade’s templating engine will compile @asset directive to Sage’s asset_path() function. You can find this directive and create your own inside app/setup.php.

#More On Blade

It would take a whole separate article to cover everything there is about Blade but I’ll point you to a very well written Blade documentation. I recommend to keep it close while you’re learning Blade syntax. And once you have no trouble with the basics you can read more about @component, @stack, and more advanced conditional directives.

#Passing Data To Templates

Sage was created with an intention to be used following Model-View-Controller architectural pattern. So we’ll abstract as much logic as possible from templates into controllers.

#Creating New Controllers

You can find default App.php and FrontPage.php controllers in app/Controllers folder. The first important thing about controllers is PSR-4 autoloading. In short, when you create new controllers you should use WordPress template hierarchy and stick to the same camel case naming standards for files and class names.

#Controller Methods

Mainly you’ll want to use two types of functions inside a class – public and public static.

Here is how default public function looks within Sage.

class App extends Controller
    public function siteName()
        return get_bloginfo('name');

When a controller runs, public function siteName is converted to snake_case and in this case becomes $site_name inside Blade template.

All public functions will run automatically so you should only use public function for returning data not printing or else it will just print in the page’s <head>.

If you want to run a function in a specific place, then you should use public static function. Here is Sage’s default title() function that prints the appropriate title.

public static function title()
        if (is_home()) {
            if ($home = get_option('page_for_posts', true)) {
                return get_the_title($home);
            return __('Latest Posts', 'sage');
        if (is_archive()) {
            return get_the_archive_title();
        if (is_search()) {
            return sprintf(__('Search Results for %s', 'sage'), get_search_query());
        if (is_404()) {
            return __('Not Found', 'sage');
        return get_the_title();

Now, you have to call a static function using a controller’s class name. In this case App::title().

In Blade it would look like this:

<h1>{{ App::title() }}</h1>

We’re escaping and echoing a function call inside a heading.

Something to keep in mind that unlike public function a static public function name is not converted to snake case.

Now for internal functions inside a controller, you can use protected function which will not be exposed to Blade.

Also, you should be mindful about putting public functions inside App.php because they will run sitewide so make sure that’s the place where they belong. The best practice is to create a controller for each template where you want to return some data as variables.

#Advanced Custom Fields Module

Since version 2.0.0 Controller introduced a brilliant new functionality to automate passing on ACF fields.

So before when I had an ACF group field “header” I would grab it and convert group array to return header object.

public function header() {
    $field = get_field('header');

    return (object) [
        'title'     => $field['title'] ?? null,
        'subtitle'  => $field['subtitle'] ?? null,
        'text'      => $field['text'] ?? null,

So in my template, I would output my data by accessing header properties while keeping my template clean and readable.

  <h1>{{ $header->title }}</h1>
  <p>{{ $header->subtitle }}</p>
  <p>{{ $header->text }}</p>

Now with the new ACF module all I need is a single line and my values will be returned as objects.

protected $acf = 'header';

And if you’re using repeater or flexible fields it’s an even bigger time saver. I love it!

#Controller Partials

If you find yourself copy-pasting the same snippets of code through different controllers than I suggest to use PHP traits to create reusable components.

Let’s create a trait RecentPosts that we can reuse throughout different controllers. We’ll place our partial in Partials folder in app/Controllers/Partials/RecentPosts.php. And the code for a trait:


namespace App\Controllers\Partials;

trait RecentPosts
    public function recent_posts()
        $args = [
            'post_type'           => 'post',
            'posts_per_page'      => 3,
            'orderby'             => 'date',
            'order'               => 'DESC',

        return $query = new \WP_Query($args);

Now just drop this in FrontPage.php controller and any other controller to pass on $recent_posts variable to Blade template.


namespace App\Controllers;

use Sober\Controller\Controller;

class FrontPage extends Controller
  use Partials\RecentPosts;

You can learn more about the use of Controller from its documentation.

#Wrapping Up

I urge you to stay open-minded. Sage can look very different from the way you’re used to doing things. But if you give it a try I’m confident you’ll love it.

Also, the barrier to entry is lower than you might think. You don’t need to know the ins and outs of every tool that Sage uses. Blade templates support regular PHP syntax so you can approach it with baby steps until you’re comfortable.

You’ll learn as you go but Sage will force you to step out of the WordPress bubble. And as a result, you’ll learn modern development practices. The best thing – you’ll be able to apply those skills beyond WordPress development. You’d be surprised how similar Laravel’s app setup looks after working with Sage for a while.

Sage has certainly made me a better web developer, and I wish you the same.

Leave a Reply

Your email address will not be published. Required fields are marked *