roots/sage
Sage is a modern WordPress starter theme from Roots, bringing Laravel-style Blade templates, Acorn, Tailwind CSS, and Vite for a fast, elegant development workflow and cleaner, more maintainable theme code.
Sage is a WordPress starter theme that borrows Laravel idioms — Blade, Composer autoloading, Acorn service providers, Vite, and Tailwind — rather than a package you install into a Laravel app. Treat it as a theme scaffold you fork and own.
Minimal setup
composer create-project roots/sage my-theme
cd my-theme
npm install
npm run dev
Then place or symlink the theme into wp-content/themes/, activate it in WordPress, and open the site. npm run dev starts Vite with HMR; Blade/PHP changes trigger a full reload if refresh: true is enabled in vite.config.js.
Where to look first
resources/views/layouts/app.blade.php — the document shell, wp_head(), wp_footer(), and the @vite(...) entry points.resources/views/ — WordPress template hierarchy as Blade files: index.blade.php, single.blade.php, page.blade.php, archive.blade.php, search.blade.php, 404.blade.php.resources/views/components/ and resources/views/partials/ — reusable Blade components and partials.app/setup.php and app/filters.php — theme supports, menus, and WordPress hooks.app/Providers/ThemeServiceProvider.php — Acorn bootstrapping and service provider registration.vite.config.js, package.json, tailwind.config.js, composer.json — build and dependency surface.First use case
Convert a page to Blade and style it with Tailwind:
resources/views/page.blade.php.layouts.app, define a content section, and use Tailwind classes.npm run dev running.npm run build before deploying.{{-- resources/views/page.blade.php --}}
@extends('layouts.app')
@section('content')
<main class="mx-auto max-w-3xl px-4 py-12">
<h1 class="text-3xl font-bold">{{ get_the_title() }}</h1>
<div class="prose mt-6">{!! get_the_content() !!}</div>
</main>
@endsection
Blade layouts and template hierarchy
Sage keeps WordPress template naming, but the files are Blade. A single-{post_type}.blade.php or page-{slug}.blade.php is picked up by the normal WordPress hierarchy. Keep wp_head() and wp_footer() in the layout, and use @yield/@section for page content.
{{-- resources/views/layouts/app.blade.php --}}
<!doctype html>
<html @php(language_attributes())>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@php(wp_head())
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body @php(body_class())>
@yield('content')
@php(wp_footer())
</body>
</html>
Components for repeated UI
Use anonymous components for presentational pieces and class components when you need logic. This is the closest day-to-day equivalent to Laravel Blade components.
{{-- resources/views/components/card.blade.php --}}
@props(['title', 'href' => null])
<div {{ $attributes->merge(['class' => 'rounded-lg border p-6']) }}>
<h2 class="text-lg font-semibold">{{ $title }}</h2>
<div class="mt-2 text-gray-600">{{ $slot }}</div>
@if ($href)
<a class="mt-4 inline-block text-blue-600" href="{{ $href }}">Read more</a>
@endif
</div>
<x-card title="Hello" href="{{ get_permalink() }}">
{!! get_the_excerpt() !!}
</x-card>
View composers for data
Rather than scattering get_field() and WP_Query calls across templates, put data preparation in app/View/Composers. A composer declares which views it targets and returns data from with().
namespace App\View\Composers;
use Roots\Acorn\View\Composer;
class PageComposer extends Composer
{
protected static $views = ['page', 'page-*'];
public function with(): array
{
return [
'subtitle' => get_field('subtitle') ?: '',
'related' => get_posts([
'post_type' => 'post',
'posts_per_page' => 3,
]),
];
}
}
If a composer does not appear to run, check how composers are registered in your Acorn configuration or ThemeServiceProvider.
WordPress loops in Blade
For simple loops, use the Acorn Blade directives where available (@posts, @endposts, @query) or set up a WP_Query in a composer and iterate it in Blade. Keep the loop markup in partials so archive, search, and index templates can share it.
@query(['post_type' => 'post', 'posts_per_page' => 5])
@posts
@include('partials.card', ['post' => $post])
@endposts
@endquery
Vite entry points
Add new CSS/JS entry points in vite.config.js, then reference them with @vite([...]) in the layout or a specific template. Do not hard-code /dist or /build URLs.
// vite.config.js
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
})
Run npm run dev during development and npm run build for production. HMR handles CSS/JS; Blade changes reload the page when refresh: true is set.
Tailwind configuration
Point Tailwind’s content globs at every place class names can appear: Blade views, PHP under app/, and JS. If you construct class names dynamically, add them to safelist or use complete class strings in the source.
// tailwind.config.js
export default {
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
'./app/**/*.php',
],
theme: { extend: {} },
plugins: [],
}
WordPress hooks and Acorn
Put add_action/add_filter calls in app/setup.php or app/filters.php, not in Blade templates. Use Acorn service providers for larger integrations, custom Blade directives, or bindings. For a Laravel developer this is the familiar service-provider pattern applied to WordPress hooks.
// app/setup.php
add_action('after_setup_theme', function () {
add_theme_support('title-tag');
add_theme_support('post-thumbnails');
register_nav_menus([
'primary' => __('Primary Navigation', 'sage'),
]);
});
Menus, search, and forms
WordPress functions that echo need to be captured or invoked directly in Blade. For menus:
@php
wp_nav_menu([
'theme_location' => 'primary',
'container' => false,
'menu_class' => 'flex gap-4',
]);
@endphp
For search, either call get_search_form() or maintain a Blade search form. If you override the search form or search results template, remember that upstream fixes — such as the v11.2.1 quotation-mark handling — will not apply automatically.
Typical workflow
resources/views/.npm run dev, verify HMR.npm run build and commit the built assets if your deployment requires them.wp acorn optimize:clear when views or config changes are not reflected.Sage is a fork, not a dependency
Upstream releases are merges or cherry-picks into your theme, not composer update events. Assign an owner and review upstream monthly. Major versions have historically changed build tooling and Acorn coupling, so budget for periodic rebuild work.
Vite manifest errors
In production, @vite(...) reads the manifest generated by npm run build. If the build step is skipped or stale, the site fails at runtime. Clear public/build and rebuild; ensure CI runs npm run build before deployment.
HMR and local URLs
If HMR connects but assets fail, check that the Vite dev server host/port matches how you access WordPress. Misconfigured server.host or hmr.host causes mixed-content or connection errors. Also confirm WP_HOME/WP_SITEURL are consistent with the URL in the browser.
Blade view cache
Compiled Blade views can go stale after deploys or provider changes. Use wp acorn optimize:clear (with WP-CLI and Acorn available) or clear the relevant storage/framework/views directory. If wp acorn is unavailable, check that WP-CLI is installed and Acorn is bootstrapped.
Template hierarchy surprises
A Blade template is only used if its filename matches the WordPress hierarchy. If page-about.blade.php is ignored, verify the page slug, whether a more specific template exists, and whether a filter in app/filters.php overrides template selection. Remember that index.blade.php is the fallback.
Tailwind purge misses dynamic classes
Classes built by string concatenation — "text-{{ $color }}-500" — will not be detected. Use full class names in source, a mapping array, or safelist. This is the most common “why is my style missing in production but present in dev” issue.
Escaping and echoing WordPress functions
Use {{ }} for escaped output and {!! !!} only for trusted HTML. Many WordPress functions echo directly (the_title(), the_content(), wp_nav_menu()), so call them inside @php(...) or capture with output buffering. Avoid mixing raw PHP and Blade unnecessarily.
Search form overrides and the v11.2.1 patch
The v11.2.1 release is a narrow fix for quotation marks in the search query field. If your theme overrides searchform.blade.php, search.blade.php, or custom search result markup, you will not inherit it. Port the change manually and smoke test quoted phrases, apostrophes, and empty queries.
Composer autoload after new classes
After adding classes under app/, run composer dump-autoload if they are not being discovered. This is especially relevant for new composers, components, or service providers.
Do not edit built assets
Treat public/build as generated output. Make changes in resources/ and rebuild. Manually editing hashed assets creates drift and breaks on the next build.
Extension points
Useful places to extend without fighting the theme:
Blade::directive(...).app/Providers/, registered in your Acorn config.vite.config.js for asset processing or aliases.app/filters.php for WordPress filter customization.Debugging checklist
content globs and run a production build locally.app/setup.php/app/filters.php are loaded and that the hook name and priority are correct.with() returns the expected keys.Version compatibility
Sage 11 expects PHP 8.1+, a modern WordPress 6.x, Composer, Node LTS, and Acorn. Check these before merging upstream changes or upgrading the host. Node and PHP drift are common causes of build failures that look like theme bugs.
How can I help you explore Laravel packages today?