Skip to main content

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's Views/, 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:

StateMeaningExit code
okmarker matches the module's current view0
unpinnedno marker — drift is not tracked for this file0
driftthe module's view changed after the override was signed1
orphanthe module or the view it overrides is gone1

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.