Building a Production notACMS Theme — From Boilerplate to Live
symfony,static-site,architecture,rwdPart 7 of 8
- 368 Tests for a Static Site Generator — Why Bother?
- 79 Bugs in a Symfony Codebase That Passed PHPStan
- XSS, Open Redirects, and Path Traversal on a 'Static' Site
- The Local Override Pattern — Symfony Templates Without Forking
- Open-Sourcing notACMS — The Complete Checklist
- How notACMS Builds a Static Site — The Full Pipeline
- Building a Production notACMS Theme — From Boilerplate to Live
- Full-Text Search for Static Sites — Pagefind in Production
This is part 7 of a series on preparing notACMS for open-source release. Part 6 covered the build pipeline. The original WordPress to Symfony series covers the migration itself.
Part 4 explained how the local/ override pattern works in principle — Twig namespace priority, SCSS cascade ordering, and the merge-at-build-time model. This post builds a real theme with it, end to end. Not a toy example. A production-ready theme with custom components, responsive images, a sidebar, and search.
The boilerplate starting points
The docs/customization/ directory contains six boilerplate sets, each demonstrating one override pattern:
| Directory | Pattern | What it shows |
|---|---|---|
starter-extend/ |
Minimal base override | Extends @base, adds app-local entrypoint |
block-override/ |
Block-level customisation | Overrides individual Twig blocks |
full-override/ |
Complete base replacement | Full base.html.twig copy — maximum control |
material-cards/ |
Card-based blog layout | Horizontal post cards with image on the left |
translation-override/ |
Custom translatable strings | Add or replace translation keys in local/translations/ |
custom-post-card/ |
Single component override | Replace just one card component + its SCSS |
custom-footer/ |
Full base for footer | Replace base because footer is not a block in core |
self-hosted-fonts/ |
Self-hosted typography | @font-face declarations + preload hints |
twig-filter/ |
Custom Twig filter | #[AsTwigFilter] attribute, zero YAML config |
php-service-decorator/ |
Service decoration | #[AsDecorator] to replace core behaviour |
We will start with material-cards and build it into a complete theme.
Step 1: Set up the base
Copy the boilerplate into local/:
cp -r docs/customization/starter-extend/. local/
The boilerplate contains two files. First, local/templates/base.html.twig — the minimal base override:
{% extends '@base/base.html.twig' %}
{% block stylesheets %}
{{ importmap('app') }}
{{ importmap('app-local') }}
{% endblock %}
The @base/ prefix pins to core templates explicitly. Without it, extends 'base.html.twig' would search local/ first — creating a circular reference where local extends itself. The stylesheets block adds the app-local entrypoint alongside the core app entrypoint.
Second, local/assets/app.js — the local JS entrypoint:
import './styles/app_local.scss';
This file triggers app-local to appear in importmap.php. Its sole job is importing the local SCSS. Any additional JS imports go here too.
Step 2: Override SCSS tokens
A theme's visual identity lives in two files: local/assets/styles/_tokens.scss (CSS custom properties) and local/assets/styles/_variables.scss (SCSS constants).
The demo theme uses a dark amber-phosphor palette. Here is what a theme author changes in _tokens.scss:
:root {
--bg: #171717; /* near-black page background */
--bg-sidebar: #0f0f0f; /* even darker sidebar */
--text: #e8e8e8; /* light grey body text */
--text-muted: #8c8c8c; /* subdued secondary text */
--accent: #f59e0b; /* amber highlight colour */
--border: #404040; /* visible but not prominent */
--code-bg: #1c1c1c; /* code block background */
}
And in _variables.scss:
$font: 'Inter', ui-sans-serif, system-ui, sans-serif;
$font-mono: 'JetBrains Mono', ui-monospace, monospace;
$radius-sm: 4px;
$radius: 6px;
$radius-md: 8px;
Everything else in the cascade — _base.scss, _components.scss, _prose.scss — uses these variables. Change --accent and every button, link, badge, and focus ring updates. No component SCSS touched.
Step 3: Override Twig blocks
base.html.twig defines around 20 {% block %} regions. The ones you will actually override:
stylesheets — done in step 1. Adds app-local importmap.
favicons — custom icon paths:
{% block favicons %}
<link rel="icon" href="{{ asset('local/favicon.svg') }}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{ asset('local/apple-touch-icon.png') }}">
{% endblock %}
Local assets use the local/ path prefix because asset_mapper.yaml maps local/assets/: local.
navigation — replaces the entire nav include:
{% block navigation %}
{{ include('components/navigation.html.twig') }}
{% endblock %}
This delegates to a local component override — the same include call but it resolves to local/templates/components/navigation.html.twig first.
sidebar_top and sidebar_bottom — custom widget content:
{% block sidebar_top %}
<div class="widget widget-cta">
<h3>Work with me</h3>
<p>I build custom Symfony applications and static sites.</p>
<a href="{{ path('contact_' ~ locale) }}" class="btn btn-primary">Get in touch</a>
</div>
{% endblock %}
footer — not a block in core. The footer is hardcoded HTML in base.html.twig. To customise it, you must replace the entire base.html.twig with a full copy. This is the only part of the system that requires a full template override. The alternative is to add a custom Twig block in a local base override and update all page templates to use it, but full replacement is simpler for most cases.
Step 4: Override component templates
The heart of theming. Copy a component from templates/components/ into local/templates/components/ and edit it:
cp templates/components/post_card.html.twig local/templates/components/post_card.html.twig
The include call does not change:
{{ include('components/post_card.html.twig') }}
Template resolution searches local/templates/ (root namespace '') first, then falls through to templates/ (core). The caller does not know or care which file is used.
The six components you will most commonly override:
post_card.html.twig — the main blog card. Variables received: post (ContentItem), featured (bool), locale (string), new_post_days (int). Local override might add a horizontal layout, move the image to the right, or swap the meta line order.
post_card_mini.html.twig — compact card for sidebar recent posts and related posts. Same variables, smaller markup, no image.
navigation.html.twig — main nav with links and language switcher. Variables: locale, access to content_item() and path() Twig functions. Local override adds, removes, or reorders nav items.
sidebar_top.html.twig — search form widget. Variables: sidebar (SidebarData value object). Local override replaces the search form with a CTA widget or custom content.
sidebar_bottom.html.twig — recent posts, categories, tags widgets. Variables: sidebar, locale, filter_type, filter_value. Local override changes the category/tag filter presentation or limits the recent post count.
breadcrumb.html.twig — breadcrumb trail. Variables: breadcrumbs (array from breadcrumbs() Twig function). Local override changes the separator from / to → or restructures the markup.
pagination.html.twig — prev/next page links. Variables: current_page, total_pages, base_url, paginated_route. Local override styles the pagination container.
Step 5: Create custom components
Extract repeated UI patterns into new files in local/templates/components/:
local/templates/components/hero_banner.html.twig:
<section class="hero-banner">
<h1>{{ title }}</h1>
{% if subtitle is defined %}
<p class="hero-banner__subtitle">{{ subtitle }}</p>
{% endif %}
</section>
Included from the homepage template:
{% block body %}
{{ include('components/hero_banner.html.twig', {
title: site_name,
subtitle: site_description
}) }}
{% endblock %}
Components receive variables from the calling template context — no special registration, no manifest, no autoloading config. Just a Twig file in the right directory.
Step 6: Add local JavaScript
The app-local entrypoint imports additional behaviours:
// local/assets/app.js
import './styles/app_local.scss';
import './js/theme-toggle.js';
import './js/scroll-animations.js';
JS files live in local/assets/js/ and are compiled into public/assets/local/ by asset-map:compile. They are accessible in templates via:
<script src="{{ asset('local/js/theme-toggle.js') }}" defer></script>
Stimulus controllers auto-discovered from local/assets/controllers/ follow the Symfony convention — just place a *_controller.js file there and reference it with data-controller attributes in the markup.
Step 7: Add custom Twig functions and filters
Place PHP files in local/src/ under the NotACms\Local\ namespace:
<?php
declare(strict_types=1);
namespace NotACms\Local\Twig;
use Symfony\Bridge\Twig\Attribute\AsTwigFilter;
use Twig\Extension\AbstractExtension;
final class ReadingTimeExtension extends AbstractExtension
{
#[AsTwigFilter('reading_time')]
public function readingTime(string $html, int $wordsPerMinute = 200): string
{
$wordCount = str_word_count(strip_tags($html));
return (string) max(1, (int) ceil($wordCount / $wordsPerMinute));
}
}
Zero manual service registration. The services.yaml file already scans local/src/:
services:
NotACms\Local\:
resource: '../local/src/'
Symfony autodiscovers classes with #[AsTwigFilter], #[AsTwigFunction], or #[AsTwigExtension] attributes. Use the attribute, place the file in local/src/, and the function is available in all templates.
Step 8: Decorate a service
Change core behaviour without modifying core code. Place a decorator class in local/src/Service/:
<?php
declare(strict_types=1);
namespace NotACms\Local\Service;
use NotACms\Service\Turnstile\TurnstileValidatorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator(decorates: TurnstileValidatorInterface::class)]
final readonly class TurnstileSkipDecorator implements TurnstileValidatorInterface
{
public function verify(string $token, ?string $remoteIp = null): bool
{
return true;
}
}
The #[AsDecorator] attribute tells Symfony to wrap the real TurnstileValidator with this decorator. All code that type-hints against TurnstileValidatorInterface now receives the decorator — which skips verification entirely. Useful in local dev where Cloudflare Turnstile cannot resolve to localhost. Remove the file in production, and the real validator takes over.
Step 9: Test the theme
Run ddev build and check every page type:
- Homepage — hero banner, post cards, CTA section
- Blog listing — pagination, filter links, card layout
- Single post — featured image, reading progress bar, tags, series nav, related posts
- Category page — filtered listing with category heading
- Tag page — filtered listing with tag heading
- Archive month/year — archive listing with date heading
- Static pages — about, contact, projects, privacy policy
- Search page — Pagefind results with custom styling
- Error pages — 404 and 500 with theme styling
- Styleguide —
/styleguide/showing all components in isolation
The styleguide page is the single most valuable debugging tool. Update local/templates/page/styleguide.html.twig to include new custom components. Run the build, open the styleguide, and every component — core and custom — is visible in one page with sample data.
Check responsive breakpoints on mobile (375px), tablet (768px), and desktop (1280px). Verify that asset fingerprinting produced new hashes in public/assets/ — if the hashes match the previous build, the theme's SCSS did not change.
The template lookup mechanism
Understanding exactly how Twig resolves templates prevents silent bugs:
include('components/post_card.html.twig')
1. local/templates/components/post_card.html.twig ← root namespace '' (first)
2. templates/components/post_card.html.twig ← core (fallback)
extends '@base/base.html.twig' ← explicitly pinned to core, never local
extends 'base.html.twig' ← searches local first, then core
The configuration that makes this work, from config/packages/twig.yaml:
twig:
paths:
'%kernel.project_dir%/local/templates': '' # root namespace — highest priority
'%kernel.project_dir%/templates': 'base' # @base namespace — explicit pin target
The root namespace ('') always has priority. Core templates are reachable via @base/ when an explicit pin is needed. Local templates that want to extend from core use {% extends '@base/base.html.twig' %} — this never changes, even if a local base.html.twig also exists.
The SCSS cascade strategy
Two importmap entrypoints create the cascade without !important:
Browser loads:
1. public/assets/app-{h1}.css ← core cascade (tokens → variables → components → ...)
2. public/assets/local/app-{h2}.css ← local cascade (tokens → variables → custom components)
Result:
Local rules load after core rules.
On equal specificity, local wins.
No !important anywhere.
This is the same mechanism the local/ pattern described in part 4, now applied to an actual theme build. The separation of concerns is clean: core provides base styles through variables, local overrides those variables and adds custom component styles. The cascade does the rest.
Comparison to other approaches
| Approach | Theme mechanism | Override model | Limitations |
|---|---|---|---|
| WordPress child themes | Separate directory, @import parent CSS |
Full file replacement only | Fragile — parent updates break child if selectors change |
| Hugo / Jekyll themes | Go/Liquid templates, config.toml |
Config-driven, limited to exposed parameters | Cannot override arbitrary templates |
| npm-based SSG themes | npm install theme-name, config file |
Plugin or config-based | Heavy toolchain, theme is a dependency |
notACMS local/ |
One directory, merge at build | File-level override + block-level extension | Footer requires base replacement |
notACMS themes are just Twig and SCSS. No special format, no theme manifest, no registration. The entire "theme system" is two lines in twig.yaml and a conditional entry in importmap.php. Copy a file, edit it, build.
Part 8 dives into the search layer: Pagefind's WASM indexing, the two search UIs (standalone page and Cmd+K overlay), production deployment on ARM64, and what Pagefind cannot do.