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 %}

footernot 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:

  1. Homepage — hero banner, post cards, CTA section
  2. Blog listing — pagination, filter links, card layout
  3. Single post — featured image, reading progress bar, tags, series nav, related posts
  4. Category page — filtered listing with category heading
  5. Tag page — filtered listing with tag heading
  6. Archive month/year — archive listing with date heading
  7. Static pages — about, contact, projects, privacy policy
  8. Search page — Pagefind results with custom styling
  9. Error pages — 404 and 500 with theme styling
  10. 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.