A small change in Moodle has six required files and one silent SCSS failure

I wanted to swap the default font in a Moodle 5 install. Specifically: keep Boost, the stock Moodle theme, but use Inter from Google Fonts instead of the Bootstrap system stack. The official path is “make a child theme of Boost.” This sounds like ten minutes of work. 🐳

It took an afternoon. Not because Moodle theming is hard exactly — it’s actually quite powerful — but because the developer experience has a handful of rough edges that all bite on the same kind of small change. This post is the postmortem.

The setup, briefly

Moodle’s theme system works the way a lot of Cascading-Style-Sheet (CSS) framework users would expect: there’s a base theme (Boost) and you make a child theme that inherits from it. Override SCSS variables, override layout templates, override language strings — anything you don’t override falls through to the parent. The mechanism is solid.

What I wanted out of my child theme, called newboost, was modest: import Inter, set it as $font-family-sans-serif, done. The whole brief is one variable override and one external font file.

First mistake: copying Boost wholesale

My first instinct was the worst possible one: cp -r theme/boost theme/newboost, change the name in config.php and version.php, reload the page. Moodle collapsed with a fatal error.

The reason is the kind of thing you only see if you actually look at version.php:

1
2
3
4
5
6
<?php
defined('MOODLE_INTERNAL') || die();

$plugin->version   = 2026042000;
$plugin->requires  = 2026041000;
$plugin->component = 'theme_boost';    // <- still says boost

I’d copied the file into theme/newboost/ but the $plugin->component string was still ‘theme_boost’. As far as Moodle’s plugin loader is concerned, that’s two Boosts trying to register with the same component name — fatal, refuses to load. And even if I’d fixed the component name, the copy had dragged along every classes/ file, all of which were namespaced under theme_boost\… — so the class loader sees two implementations of every Boost class and falls over.

The right thing is the opposite of “copy everything”: a Boost child theme should be the minimum set of files that override what you actually want different. For my single-variable change, that turned out to be six files. Total line count, around 50.

The six files

If you ever need this list as a reference, here it is. Everything goes under theme/newboost/:

  • version.php$plugin->component = ‘theme_newboost’, depends on theme_boost.
  • config.php$THEME->parents = [‘boost’] plus an scss closure.
  • lib.php — one function that returns your overriding SCSS as a string, then appends Boost’s default.scss for the rest of the cascade.
  • lang/en/theme_newboost.phppluginname, choosereadme, and configtitle. That’s it.
  • settings.php — at minimum the defined(‘MOODLE_INTERNAL’) || die(); guard. Optionally one admin_settingpage registration so the theme settings page resolves.
  • scss/preset/default.scss — the actual variable override.

Everything else (templates, layouts, icons, mustache, JavaScript) inherits from Boost. The whole point of a child theme is that you only write what’s different.

The Inter override that looked right and broke everything

Here’s what I put in scss/preset/default.scss on the first try:

1
2
3
4
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

$font-family-sans-serif: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$font-family-base: $font-family-sans-serif;

If you paste this into a real CSS file in a browser, it works fine — @import url() is part of CSS. But the compiler in play here is scssphp, the Pure-PHP-Sass-Compiler (no native sass binary, no node tooling — just PHP). And scssphp sees @import url(…), decides it should resolve url as a local file path, fails, and throws:

1
CompilerException: `url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap")` file not found for @import: line: 5, column: 1

So far so understandable. The infuriating part is the next layer. Moodle’s CSS-content function wraps the compile in try { … } catch (Throwable $e) { debugging(…) } — meaning the exception is swallowed, demoted to a debug-log message, and execution falls through to the parent theme’s pre-compiled CSS. There’s a literal file at theme/boost/style/moodle.css, about 1 MB of pre-baked Boost output, and Moodle helpfully serves that when SCSS compilation returns nothing.

From the browser’s perspective: the page renders, all the styles are there, the colours are right, Boost looks like Boost — and my override is nowhere. No error in the console. No notice on any admin page. No entry in the Hypertext-Transfer-Protocol (HTTP) error log. Pure silent failure with a fully styled fallback covering it up.

How I found it

I started by suspecting the things you always suspect first: stale caches (purged), browser cache (hard-refreshed), theme designer mode (enabled, then later disabled — more on that). I used the chrome-devtools Model-Context-Protocol (MCP) server to inspect document.styleSheets on the live page, which showed the main styles.php?theme=newboost&type=scss entry was loading but had zero matching variables for Inter. I dumped the compiled Cascading-Style-Sheet (CSS) and confirmed my override wasn’t in it. None of that pointed at the actual cause.

What worked, eventually, was a 10-line Hypertext-Preprocessor (PHP) script that called Moodle’s internal $theme->get_css_content_from_scss() directly, with the try/catch bypassed via reflection:

1
2
3
4
5
6
7
8
9
$compiler = new core_scss([]);
$compiler->append_raw_scss($scss($theme));
$compiler->setImportPaths($paths);
try {
    $css = $compiler->to_css();
} catch (Throwable $e) {
    echo "CAUGHT " . get_class($e) . ": " . $e->getMessage() . PHP_EOL;
    echo $e->getTraceAsString();
}

The stack trace appeared in about two seconds. Lesson learned: when SCSS “compiles to nothing” in Moodle, the failure is almost always in the compile itself, not in the caching or the routing. Reach for a script like that early. 💡

The fix

Two options. The cleanest is: don’t @import Google Fonts from SCSS at all. Instead, inject a real <link rel=”stylesheet”> tag via Moodle’s additionalhtmlhead config setting, which Moodle drops into every page’s <head>:

1
2
3
4
5
set_config('additionalhtmlhead',
    '<link rel="preconnect" href="https://fonts.googleapis.com">' .
    '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' .
    '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">'
);

With that in place, the SCSS file shrinks to just the variable override and compiles without issue:

1
2
$font-family-sans-serif: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$font-family-base: $font-family-sans-serif;

The other option is to self-host the woff2 files inside the theme and reference them via a local @font-face rule. Better for privacy and offline use, more work to maintain through Google Font version bumps. For a dev box I went with the <link> tag.

The second class of trap: body *

Once Inter was loading, I noticed some Bootstrap components — buttons, form inputs — were still falling back to system-ui because Bootstrap declares font-family directly on those elements rather than inheriting from body. The natural fix, and the wrong one, is:

1
body * { font-family: 'Inter', sans-serif; }

This works for text. It also breaks every icon on the site. Font Awesome icons aren’t real characters — they’re glyphs in a private icon font (“Font Awesome 6 Free”), referenced from <i class=”fa fa-star”> elements via the font-family declaration on .fa. Forcing Inter on those <i> tags makes the browser render whatever Unicode codepoint happens to occupy the icon’s glyph slot — usually a blank box, sometimes a weird character.

The cleaner fix is to be explicit about which elements need re-setting:

1
2
3
4
5
body, .btn, .form-control, .form-select, .dropdown-menu, .nav-link,
.breadcrumb, .modal, .popover, .alert, .badge, .card, .list-group-item,
h1, h2, h3, h4, h5, h6, input, textarea, select, button, label {
    font-family: $font-family-sans-serif;
}

Pedestrian, but it cooperates with how Bootstrap and Font Awesome both use font-family. Lesson learned: body * is almost always wrong. The cascade was designed for inheritance — work with it, don’t bulldoze it.

The two papercuts at the edges

Two smaller things ate time I didn’t expect to spend.

Theme designer mode. While debugging the SCSS-not-compiling issue, I turned on Moodle’s themedesignermode setting so compiled CSS wouldn’t be cached. Forgot to turn it off. Designer mode serves CSS through styles_debug.php instead of styles.php, splitting one bundle into about a hundred per-component requests — and one of those (the main SCSS slice) was returning zero bytes even though the SCSS compiled fine in isolation. The page rendered with no styles at all, like a 1996 hand-coded Hypertext-Markup-Language (HTML) document. Turning designer mode off and re-clearing caches fixed it instantly. If you’re going to enable it for debugging, set a reminder to turn it off afterwards.

The settings page that didn’t exist. Going to /admin/settings.php?section=themesettingnewboost returned “Section error!” until I did two things: register a minimal admin_settingpage in settings.php, and add a $string[‘configtitle’] entry in the language file. Until the language string cache flushes, the page heading literally renders as [[configtitle]]. Reasonable on reflection; surprising in the moment.

What I’d tell past-me

Three things:

  1. Don’t start from cp -r theme/boost. Read one of the existing minimal Boost child themes on GitHub, count the files (six), and copy that shape. The Boost source tree is enormous and dragging the whole thing in is a guaranteed failure mode.
  2. When SCSS “compiles to nothing,” Moodle silently falls back to the parent’s pre-compiled CSS. The failure is almost always in the compile itself, not the caching or routing. Bypass the try/catch and look at the actual exception — it’s right there.
  3. The CSS cascade was designed for inheritance. If Bootstrap is overriding font-family on .btn, re-override .btn specifically. Reaching for body * is the equivalent of using a sledgehammer where a Phillips-head will do, and it breaks anything that legitimately uses a non-default font-family — icon fonts being the obvious one.

Closing thought

Moodle’s theme system is more powerful than most Learning-Management-System (LMS) theming I’ve worked with. You really do get full SCSS inheritance, full template overrides, full plugin compatibility from the parent, all without forking. That’s rare and good.

But the developer experience around it has a stack of small frictions that all bite the same kind of small change. Silent SCSS errors. Parent precompiled-CSS fallback covering up your compile failure with something that looks fine. Designer-mode behavioural drift. Language-string caching. Settings-page registration boilerplate that you only know is missing because of an error message that doesn’t quite point at it. None of these are bugs exactly — they’re rough edges. Coming in expecting “the ten-minute job” sets you up to be frustrated. Budget a half-day; you’ll be fine. 🛠️

This entry was posted in Moodle and tagged , , , , . Bookmark the permalink.

Comments are closed.