The Local Override Pattern — Symfony Templates Without Forking
architecture,symfony,static-sitePart 4 of 4
- 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
This is part 4 of a series on preparing notACMS for open-source release. Part 3 covers security vulnerabilities. The original WordPress to Symfony series covers the migration itself.
You want to share a Symfony project template. Every user needs to customise it — different colours, different homepage, different navigation. Forking means they can't pull upstream updates. Configuration files can't handle template changes. Themes are too rigid.
The solution I built for notACMS is a local/ directory that merges on top of the base project at build time. Users customise local/, the rest stays untouched, and when the upstream changes they pull and merge like any other git operation.
The Problem
Three scenarios that configuration files can't solve:
- User A wants different CSS colours and a custom homepage layout
- User B wants to override just the navigation component
- User C wants to add their own translation strings
Forking is the traditional answer. But forking means every upstream update is a manual merge. For a project that gets regular improvements, that's a maintenance burden that kills adoption.
The Solution: A Merge Layer
local/ sits alongside the base project. At build time, paths resolve with priority: local/ first, base second. Three override types:
Full override — Replace an entire file. local/templates/base.html.twig replaces templates/base.html.twig completely.
Block override — Extend and override specific Twig blocks:
{% extends 'base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/custom.css') }}">
{% endblock %}
Translation override — local/translations/messages.en.yaml merges with base translations, adding or replacing keys:
# local/translations/messages.en.yaml
site.title: "My Custom Site"
nav.home: "Home"
CSS Ordering: Two-Entrypoint Importmap
The trickiest part. Local styles must load after base styles to override them via cascade:
// importmap.php
->add('app', 'assets/app.js') // base: imports app.scss
->add('local', 'assets/local/app.js') // local: imports local.scss
local.scss is imported after app.scss, so CSS cascade works correctly. No !important needed. The import map registers both entrypoints; the browser loads them in order.
Template Resolution
Kernel::build() checks for local/templates/ files and copies them to the output, overriding base templates. The local/ directory is gitignored in the user's project, but .gitkeep placeholders ensure fresh clones have the structure:
local/
├── assets/
│ └── styles/
│ └── .gitkeep
├── templates/
│ └── .gitkeep
└── translations/
└── .gitkeep
Boilerplates in docs/examples/
Copy-paste starting points for different customisation levels:
| Boilerplate | Customisation level | Use when |
|---|---|---|
starter-extend/ |
Lightest | You want to extend base with minor tweaks |
block-override/ |
Medium | You want to replace specific components |
full-override/ |
Full | You want complete control over the layout |
material-cards/ |
Theme | Complete dark theme example with local.scss |
translation-override/ |
Strings | Custom translation strings only |
What's Tracked in Git
.gitkeep files in local/ placeholder dirs so the structure exists in fresh clones. The actual override files are gitignored — they're the user's customisations. docs/examples/ contains the copy-paste templates that users start from.
Why Not a Framework?
notACMS isn't a framework. Users don't composer install notacms/core. They clone the repo, customise local/, and deploy. The pattern is simple enough to understand in 5 minutes, powerful enough to handle any customisation. The entire customisation surface is one directory.
The next post covers the full open-source release checklist — everything from the previous four posts leading to the final launch.