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 overridelocal/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.