Overriding a module's twig template from the theme
A module ships its storefront markup inside its own package:
app/Modules/<Studly>/Views/<name>.twig. Editing that file is the obvious
move and the wrong one — it is a copy of a package, so the next module release
or the next nightly cms9:auto-update either overwrites it or refuses to
update at all and reports local_changes (see
Local changes in a module).
A theme is the one place on a site that neither the module package nor the core build ever touches. So the markup a site wants to change belongs there:
themes/<theme>/modules/<identif>/<path inside Views>.twig
If that file exists, the storefront renders it instead of the module's
own view. Nothing else changes: the module keeps updating normally, and
cms9:module-drift stays clean, because nothing inside app/Modules/ was
touched.
<theme>— the active theme (settings.site_template);<identif>— the module's code identifier (share,buy_one_click,payment_method_2checkout), not the StudlyCase directory name;<path inside Views>— the path exactly as it is under the module'sViews/, including subdirectories.
Example — the share module renders app/Modules/Share/Views/share.twig.
The theme base overrides it with:
themes/base/modules/share/share.twig
Fully replacing a view
Copy the module's view into the theme path above and edit the copy:
mkdir -p themes/base/modules/share
cp app/Modules/Share/Views/share.twig themes/base/modules/share/share.twig
The override is picked up on the next request — there is no registration step and no cache to clear (see Cache below).
Changing one block instead of copying the file: @module
A full copy freezes the module's markup at the day it was copied: every later fix in the module is invisible on this site, and the drift command can only tell you that the original moved, not merge it for you. When only a fragment has to change, extend the original instead of copying it.
The original view is always reachable under the @module twig namespace:
{% extends '@module/share/share.twig' %}
{% block share_label %}
<span class="my-label">Share this page</span>
{% endblock %}
@module/<identif>/<path>.twig always resolves to the module's own file —
never back into the theme — so a file may safely extend the very view it is
overriding; there is no recursion by construction.
{% include %} works the same way, which is the option when the module's view
declares no {% block %} of its own (most of them currently do not):
<div class="my-wrapper">
{% include '@module/share/share.twig' %}
</div>
Blocks are the better contract: if you need one in a core module's view, add it to the module upstream rather than copying the file downstream.
What an override may point at
An override is confined to the active theme's directory. The resolver accepts
only a path that stays under themes/<theme>/modules/, and refuses everything
else — .. segments, absolute paths, empty or duplicated separators, a
symlink whose target leaves the theme, a NUL byte in the name, and any
extension other than .twig. A refused path is not an error: the module's own
view renders, exactly as if no override existed.
What does not survive a theme change
Overrides belong to one theme. Switch settings.site_template and every
override under the previous theme stops applying — the module's own views come
back. Copying a theme (or handing it to another site) carries its overrides
along, because they are ordinary files inside the theme directory.
Themes are not part of core-zip, so an override is also invisible to
cms9:core-drift and to the core build. Keep it in the theme's own
repository, the same as any other theme file.
unishop is the exception. That theme is pinned by the CrawlSweep HTML
parity gate, so theme overrides are deliberately disabled for it: files under
themes/unishop/modules/ are listed by the drift command but never rendered.
The same restriction already applies to BaseWidget::themeOverride.
When an override is broken
A twig syntax error or an exception inside an override does not take the page
down. The renderer falls back to the module's own view and writes one line to
writable/logs/:
ERROR - 2026-09-24 05:57:38 --> theme module override share/share.twig failed: Unclosed "(" in "@__sfovr/share/share.twig" at line 2.
It is logged at error, not warning, on purpose: production runs with
Config\Logger::$threshold = 4, and a warning would never reach the log —
the page would silently render something other than what the site intended.
Drift: cms9:theme-overrides
An override is a photograph of the module's view at one moment. When the module updates, the original may gain a field, a class or a whole block that the override does not know about — and nothing breaks loudly, the site just quietly keeps rendering last year's markup.
php spark cms9:theme-overrides # active theme
php spark cms9:theme-overrides base # a named theme
php spark cms9:theme-overrides base --module share # one module
php spark cms9:theme-overrides base --json # machine-readable
php spark cms9:theme-overrides base --write-hash # sign the current state
Both option forms work — --module share and --module=share.
The command is read-only except for --write-hash. It compares a sha256
marker in the override's first line against the module's current view:
{# cms9-override-of: sha256=782c403f0f71ef880492815406c151becb7a95bdf1b0880f9f46b7fee15bb4c8 #}
The marker lives in the file rather than in a sidecar index, because override files get copied, renamed and committed one at a time — an external index drifts silently. A twig comment does not reach the output, and an override without one still renders.
States:
| State | Meaning | Exit code |
|---|---|---|
ok | marker matches the module's current view | 0 |
unpinned | no marker — drift is not tracked for this file | 0 |
drift | the module's view changed after the override was signed | 1 |
orphan | the module or the view it overrides is gone | 1 |
So CI can simply run the command: 0 means every signed override still
matches, 1 means someone has to look. Usage errors exit 7
(EXIT_USER_INPUT) and are not confused with drift.
Workflow after a module update:
php spark cms9:theme-overrides base # drift → which views moved
php spark cms9:module-drift share --diff # what actually changed upstream
# carry the change into the override, then:
php spark cms9:theme-overrides base --module share --write-hash
Cache
Nothing has to be cleared by hand. The compiled-twig cache is keyed by the
resolved file path, and an override renders under a different template name
than the module's view, so adding, removing or editing an override produces a
different compiled template on the next request. auto_reload compares
filemtime, which covers an edit in place.
What is cached is the finished HTML: with LiteSpeed cache on, a guest page
keeps serving the previous markup until the cache is purged. Use the standard
purge — the admin panel's cache console (Optimizer), which sends
X-LiteSpeed-Purge: *. Do not delete cache files by hand. To check a change
without purging anything, request the page with a unique query string
(?nc=1), which bypasses the guest cache.
Admin panel
Overrides apply to the storefront only. Admin-panel templates go through a different renderer and are not affected.