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. 🛠️

Posted in Moodle | Tagged , , , , | Leave a comment

Local HTTPS for dev sites with Caddy, mkcert, and a reverse-proxy gotcha

Running a local web app over plain HTTP is fine until something forces your hand — a third-party SDK that refuses to load on http://, a cookie that needs the Secure flag, or an app like Moodle that nags you on every page about being insecure. At that point you want a real-looking HTTPS URL pointing at localhost, with a certificate your browser doesn’t yell about. 🔐

The combo I keep coming back to: mkcert for the cert, Caddy as the reverse proxy, and a small /etc/hosts entry to make the hostname resolve. Total setup is maybe five minutes. Here’s the shape of it.

Step 1 — make a trusted cert with mkcert

mkcert installs a local Certificate Authority (CA) into your operating system’s trust store the first time you run it. After that, every cert it issues is automatically trusted by browsers on that machine. No self-signed warning popups.

1
2
3
4
5
6
7
8
9
# One-time setup
brew install mkcert nss     # nss = trust for Firefox
mkcert -install

# Issue a cert for your local hostname
cd ~/dev/myapp
mkcert myapp.example.test
# → myapp.example.test.pem        (cert)
# → myapp.example.test-key.pem    (private key)

Pick a hostname that won’t collide with a real one. The .test top-level domain is reserved for exactly this purpose by RFC 2606, so it’s a safer choice than inventing your own. I sometimes use my company’s domain for muscle memory — that works too, but be aware you’re squatting on a real Domain Name System (DNS) name locally.

Step 2 — make the hostname resolve to your machine

Your browser asks DNS where myapp.example.test lives. DNS has no idea — so you tell your machine directly, by editing /etc/hosts:

1
sudo nano /etc/hosts

Add a line:

1
127.0.0.1    myapp.example.test

That’s macOS and Linux. On Windows, the same file lives at C:\Windows\System32\drivers\etc\hosts and you’ll need to open your editor as Administrator to save it. (I haven’t run a Windows dev box in a while — if it’s different on a current version, the principle is the same: there’s a hosts file, it maps names to IPs, your changes win over real DNS.)

Verify it took effect:

1
ping myapp.example.test    # should resolve to 127.0.0.1

Step 3 — wire up Caddy as a reverse proxy

Caddy’s config is one of the friendlier ones to write. Each site is a block; tls takes the cert pair; reverse_proxy hands the request off to your app. Your Caddyfile ends up looking like this:

1
2
3
4
5
6
7
8
9
10
{
    auto_https disable_redirects
}

myapp.example.test {
    tls /Users/me/dev/myapp/myapp.example.test.pem /Users/me/dev/myapp/myapp.example.test-key.pem
    reverse_proxy localhost:8080 {
        header_up X-Forwarded-Proto https
    }
}

The auto_https disable_redirects in the global block is important — without it, Caddy will try to do its own HTTP-to-HTTPS redirect, which is helpful in production but interferes with local dev when your app is also issuing redirects.

Reload Caddy:

1
2
3
brew services restart caddy
# or, if running in foreground:
caddy reload --config /opt/homebrew/etc/Caddyfile

Hit https://myapp.example.test. You should land on your app, served on real-looking HTTPS, with a green padlock. 🎉

The gotcha: ERR_TOO_MANY_REDIRECTS

This one bites everyone the first time. You update your app’s configured base URL from http://localhost:8080 to https://myapp.example.test, reload the page, and the browser shows:

1
2
3
This page isn't working
myapp.example.test redirected you too many times.
ERR_TOO_MANY_REDIRECTS

Here’s what’s happening. Caddy terminates Transport Layer Security (TLS) on its side, then forwards a plain http:// request to your app on port 8080. Your app looks at the incoming request, sees it arrived as HTTP, but its configured base URL is HTTPS — so it issues a 302 back to the HTTPS URL. The browser follows it. Caddy receives it, strips TLS again, forwards HTTP. Loop forever. 🔁

Two fixes, both needed:

  1. Caddy tells the app it was HTTPS originally. The header_up X-Forwarded-Proto https line in the reverse_proxy block (shown above) does this.
  2. The app trusts that header. Frameworks usually have a flag for this. In Moodle it’s $CFG->sslproxy = true; in config.php. In Laravel it’s the TrustProxies middleware. In Express it’s app.set(‘trust proxy’, 1). Without this, the app keeps thinking it’s an HTTP request and the redirect loop continues.

Both halves matter. Forwarding the header without trusting it is silent; trusting a header that isn’t being sent does nothing. 💡

The whole flow, in one mental model

  1. mkcert gives you a trusted cert for a chosen hostname.
  2. /etc/hosts points that hostname at your machine.
  3. Caddy listens on 443 with the cert, terminates TLS, forwards the request to your app’s plain-HTTP port.
  4. X-Forwarded-Proto + the app’s “trust proxy” flag tells the app the original request was HTTPS, so it stops redirecting in circles.

Once the four pieces are in place, every new project that needs HTTPS is a two-minute job: one mkcert call, one new block in the Caddyfile, one hosts entry. Worth setting up once. 🛠️

Posted in DevOps | Tagged , , , | Leave a comment

Running multiple Moodle instances side-by-side with moodle-docker

The moodle-docker project gives you a one-command Moodle dev stack — Apache, PHP, the database of your choice, mail catcher, the lot. What its README mentions only in passing is that you can run several Moodle instances on the same machine at the same time. Useful when you want to keep your current 4.5 happily ticking while you poke at a 5.0 upgrade in parallel. 🐳

Here are the bits that weren’t obvious from the docs, plus a small local.yml trick to fully isolate each instance’s database files.

Two stacks, one machine — what actually has to differ

The wrapper script bin/moodle-docker-compose is a thin layer over docker compose. Each docker compose invocation groups containers, networks, and volumes under a project name. Two stacks collide only when they share any of:

  1. Project name — same name means same containers; the second up will reconfigure the first.
  2. Host web port — only one process can bind localhost:8000 at a time.
  3. Code tree (the MOODLE_DOCKER_WWWROOT bind mount) — two Moodles writing the same source directory is a bad time.

Database volumes and the moodledata directory get scoped per project name automatically, so just changing the project name handles those for free.

The recipe, then, is: different project name + different web port + different code checkout. Express it as a per-instance env file you source before running the wrapper.

1
2
3
4
5
# md-docker/moodle-env.sh — for Moodle 4.5
export COMPOSE_PROJECT_NAME=moodle45
export MOODLE_DOCKER_WWWROOT=/Users/me/dev/lms/moodle45/html
export MOODLE_DOCKER_WEB_PORT=8000
export MOODLE_DOCKER_DB=mariadb
1
2
3
4
5
6
# md-docker/moodle-env50.sh — for Moodle 5.0
export COMPOSE_PROJECT_NAME=moodle50
export MOODLE_DOCKER_WWWROOT=/Users/me/dev/lms/moodle50/html
export MOODLE_DOCKER_WEB_PORT=8001
export MOODLE_DOCKER_DB=mariadb
export MOODLE_DOCKER_PHP_VERSION=8.3

Then drive each stack from its own shell:

1
2
3
4
5
6
7
# Terminal A — 4.5
cd md-docker && source moodle-env.sh
bin/moodle-docker-compose up -d         # → http://localhost:8000

# Terminal B — 5.0
cd md-docker && source moodle-env50.sh
bin/moodle-docker-compose up -d         # → http://localhost:8001

Both containers’ Apache listens on the container’s port 80 internally — that’s fine, they live in separate network namespaces. The host port (8000 vs 8001) is the only number that has to be unique, because that’s the one that exists on your laptop. The README’s terse warning that “you need to have set up the environment variable COMPOSE_PROJECT_NAME to just refer to the instance you expect to” is exactly the catch: source the wrong env file and you’ll act on the wrong stack. Two terminal windows, one per stack, is the easiest habit.

Where does the database actually live?

Look at db.mariadb.yml in the moodle-docker tree — the db service has no volumes: section at all. That means MariaDB’s data directory at /var/lib/mysql inside the container is backed by an anonymous Docker volume: created automatically the first time the container starts, named with a random hash, scoped to the compose project name.

You can prove it to yourself:

1
2
3
docker inspect $(bin/moodle-docker-compose ps -q db) \
  --format '{{range .Mounts}}{{.Type}} {{.Name}} → {{.Destination}}{{println}}{{end}}'
# volume   a1b2c3...    → /var/lib/mysql

The implications are worth internalizing:

  • docker compose down (no flag) — the anonymous volume survives, your data is fine.
  • docker compose down -v — the volume is destroyed, your database is gone.
  • Removing the container directly — same; the anonymous volume gets orphaned and eventually garbage-collected.

For day-to-day dev, that’s usually OK. For a development environment you’ve spent days seeding, it’s a sword hanging over your head. 🗡️

Pinning the database to a host path with local.yml

moodle-docker has a documented extension hook: if a file called local.yml exists in the md-docker directory, the wrapper merges it last into the compose invocation. It’s gitignored by default — meant exactly for per-machine overrides like this one.

1
2
3
4
5
# md-docker/local.yml
services
:
  db
:
    volumes
:
     - "${MOODLE_DOCKER_DB_DATA}:/var/lib/mysql"

Then in each env file, point at a per-instance host directory:

1
2
3
4
5
# moodle-env.sh
export MOODLE_DOCKER_DB_DATA=/Users/me/dev/lms/moodle45-db-data

# moodle-env50.sh
export MOODLE_DOCKER_DB_DATA=/Users/me/dev/lms/moodle50-db-data

Now down -v will still try to remove the volume, but a bind mount to a host directory can’t be deleted by Docker — the files stay put on your laptop. The instances are also fully isolated at the filesystem level, which is handy if you want to tar one of them for a backup or move it between machines.

Two caveats worth knowing before you do this on macOS:

  1. Bind-mounted MariaDB data on macOS Docker Desktop is noticeably slower than a named or anonymous volume, because every fsync crosses the VirtioFS boundary into the Linux VM. Fine for dev, not great for benchmarking.
  2. If you’re migrating an existing anonymous-volume database into the bind mount, copy it out with the container stopped. docker cp container_id:/var/lib/mysql/. /path/on/host/ works, but a live copy will corrupt the snapshot. For backups, a logical mysqldump is portable and uid-independent — usually the right tool.

Moodle 5.0 needs a different web root

One more bit of local.yml business if you’re running a 5.x instance. Moodle 5 moved its front controller into a public/ subdirectory — public/index.php is now the entry point, not index.php at the repo root. The moodle-docker Apache image still defaults its DocumentRoot to /var/www/html, which is fine for 4.x but means 5.x will trip the install-time “Router not configured” check and 404 on clean URLs.

The fix is one extra environment entry in local.yml, overriding the Apache image’s APACHE_DOCUMENT_ROOT variable:

1
2
3
4
5
6
7
8
# md-docker/local.yml
services
:
  db
:
    volumes
:
     - "${MOODLE_DOCKER_DB_DATA}:/var/lib/mysql"
  webserver
:
    environment
:
      APACHE_DOCUMENT_ROOT
: /var/www/html/public

The Apache config inside the container reads this env var at startup (the default site config has DocumentRoot ${APACHE_DOCUMENT_ROOT}), so a restart webserver is enough to pick up the change. Worth noting: this is a global override — if you only want it for the 5.x stack and not the 4.x one, you either need two different local.yml files swapped via the wrapper’s compose-file flag, or set the env var conditionally from the per-instance env-source script. For most dev setups, applying it to both is harmless: Moodle 4.x doesn’t have a public/ directory, so the 4.x stack just keeps using its own root and ignores the override.

While you’re in there, the same local.yml is a good place for any other per-machine compose-level overrides you accumulate — extra services, port bindings, additional bind mounts. It’s the one file moodle-docker explicitly leaves to you, gitignored by default, so you can shape it without worrying about upstream merge conflicts. 🐳


A few more Moodle 5 gotchas worth writing down.

If you’re following this recipe for a 5.x instance, three more things tripped me up that the moodle-docker README doesn’t mention:

Extra config.php entries for Moodle 5

A hand-rolled moodle/config.php (i.e. one you copied from an older Moodle install rather than generating from md-docker/config.docker-template.php) needs three small additions to keep 5.x happy:

1
2
3
4
5
6
<?php  // moodle/config.php — Moodle 5.x specific bits

ini_set('zend.exception_ignore_args', '1');

$CFG->dbtype           = 'mariadb';
$CFG->routerconfigured = true;

The zend.exception_ignore_args setting suppresses argument values in PHP exception traces — Moodle 5 throws warnings on certain debug paths without it. $CFG->routerconfigured = true tells Moodle that you’ve handled the new front-controller routing (the public/ directory move described above), so it stops nagging you on every page load. And dbtype = ‘mariadb’ is just the right driver name if you’re on MariaDB — the older ‘mysqli’ value still works, but ‘mariadb’ picks up MariaDB-specific optimisations the Moodle 5 DAL (Data Access Layer) added.

Routing outbound mail through Mailpit

The md-docker stack always runs a Mailpit container (see service.mail.yml), and the Apache image proxies its UI at /_/mail/ on the webserver — so https://your-moodle.host/_/mail/ is where reset emails, course notifications, and the rest should land.

Except: if your config.php doesn’t tell Moodle to use Mailpit’s SMTP, none of that happens. With no $CFG->smtphosts, Moodle falls back to PHP’s mail(), which uses sendmail, which isn’t installed in the moodlehq/moodle-php-apache image. The mail is silently dropped, the password-reset form still cheerfully tells you “an email has been sent,” and you’ll spend a while wondering why Mailpit is empty.

Add these two lines:

1
2
3
// Route outbound mail through the mailpit container (md-docker service.mail.yml).
$CFG->smtphosts      = 'mailpit:1025';
$CFG->noreplyaddress = 'noreply@example.com';

The hostname mailpit resolves on the compose network — no host port mapping needed. To sanity-check the wiring from the webserver container:

1
bin/moodle-docker-compose exec webserver   bash -c 'echo > /dev/tcp/mailpit/1025 && echo OK'

Composer isn’t in the image — bootstrap it on first install

The moodlehq/moodle-php-apache image ships PHP and Apache but not Composer. Moodle’s vendor directory has to be populated before the installer will run, so on a fresh checkout you need a one-shot bootstrap inside the webserver container:

1
2
3
4
5
cd ~/dev/lms/md-docker && source moodle-env50.sh
bin/moodle-docker-compose exec webserver bash -c '
  curl -sS https://getcomposer.org/installer | php --     --install-dir=/usr/local/bin --filename=composer
  cd /var/www/html && composer install --no-dev --classmap-authoritative
'

The –no-dev skips test-only dependencies and –classmap-authoritative builds an optimised autoloader — both fine for a dev box that isn’t running PHPUnit. If you do want to run the test suite, drop those flags. After this finishes once, the vendor/ directory lives in your bind-mounted code tree, so subsequent stack restarts pick it up for free.

The pattern, condensed

  • One env file per Moodle instance, each with a distinct COMPOSE_PROJECT_NAME and MOODLE_DOCKER_WEB_PORT.
  • A single shared local.yml that wires MOODLE_DOCKER_DB_DATA as a bind mount, so each instance’s MariaDB data lives at a known host path — and, for 5.x, sets APACHE_DOCUMENT_ROOT to /var/www/html/public so the new front controller is reachable.
  • Two terminals, two source ./env-files, and you can run 4.x and 5.x side-by-side without either one knowing the other exists. 🎉

None of this is exotic — it’s just three pieces of Docker Compose hygiene that the moodle-docker README hints at but doesn’t quite spell out. Once you have the env-file-per-instance habit, parallel Moodle versions stop feeling fragile.

Posted in DevOps, Moodle | Tagged , , | Leave a comment

Multi tenancy in Laravel using stancl/tenancy

I spent a day inside stancl/tenancy (the database-per-tenant Laravel package) shipping a real change to a multi-tenant Software-as-a-Service (SaaS) application, and a few things finally clicked for me — none of which were obvious from the documentation alone. 💡 Here are the notes I wish I’d had on day one.

The VirtualColumn trait, and why $tenant->data is null

Open any tenant record in tinker and the first thing you’ll notice is bizarre: $tenant->data returns null. But the database row clearly has a JavaScript Object Notation (JSON) blob full of data. What gives?

It’s the VirtualColumn trait. On retrieval, Stancl decodes the JSON column into individual top-level model attributes, then nulls the data property itself. So a row like this:

1
{ "ready": true, "region": "AMER", "has_pending_enrollment": null }

…becomes $tenant->ready, $tenant->region, and $tenant->has_pending_enrollment. Reach for $tenant->data[‘ready’] and you’ll get nothing back, then waste an hour wondering why your test is broken (don’t ask me how I know).

The takeaway: treat any data-bag key as if it were a real column. Read it via the attribute, write it via the attribute:

1
2
3
4
5
6
7
// Read
$ready = $tenant->ready;
$pending = $tenant->has_pending_enrollment;

// Write — Stancl re-encodes into the data JSON on save
$tenant->update(['ready' => true]);
$tenant->update(['has_pending_enrollment' => '2026-06-22 10:30:00']);

You only need the raw data->key form when you’re going through the query builder (for whereBetween over a Java Script Object Notation path, or an atomic update that bypasses the model). For anything else, attribute access is cleaner and matches how the rest of the codebase reads.

Finding a tenant: the central side vs the tenant side

This one trips people up because Tenant and the tenant’s own data live on different connections. The central database holds the Tenant and Domain models — the registry of who exists. Each tenant has its own database holding its actual application data.

From anywhere in the app:

1
2
3
4
5
6
7
use App\Models\Central\Tenant;

// Find by ID (central registry)
$tenant = Tenant::find('acme');

// Get the current tenant inside a tenant-context request
$current = tenant(); // global helper

The tenant() helper returns the model for whichever tenant initialized the current request. Inside controllers, jobs, or anything routed through a subdomain, it’s already there. Outside of that — central admin code, background scripts, scheduled jobs — you’ll often initialize tenancy yourself:

1
2
3
tenancy()->initialize($tenant);  // swap default connection to the tenant DB
// ... do stuff in tenant context ...
tenancy()->end();                 // back to central

If your code runs in both contexts, always check tenancy()->initialized before re-initializing, and use try/finally so a thrown exception doesn’t strand a connection.

A pattern: storing “when” instead of “if”

Here’s the change that actually prompted all this. We had a scheduled job that fanned out to every tenant every 15 minutes — and for 95% of them, it was a no-op. The job would boot a tenant connection, query for pending work, find nothing, exit. Multiply by hundreds of tenants and the waste was real.

The obvious fix is a flag: has_pending_enrollment = true, the scheduler filters on it. But that has a subtle problem. A campaign saved today for next week would flip the flag immediately, and the cron would pay the full fan-out cost for a week of no-ops before the campaign was actually due.

The better shape: store when the work is due, not just if. A nullable datetime instead of a boolean. Then the scheduler filter becomes time-aware:

1
2
3
4
5
6
Tenant::where('data->ready', true)
    ->whereBetween('data->has_pending_enrollment', [
        Carbon::now()->subHours(6),
        Carbon::now(),
    ])
    ->get();

Future-dated tenants are skipped at the Structured Query Language (SQL) level for free. When the campaign’s startdate enters the lookback window, the tenant gets picked up, the job runs, marks the work done, and either points the flag at the next pending campaign or sets it to null. The boolean couldn’t encode “skip me until then” — a datetime can.

Watch the format when you store datetimes in JSON

One small gotcha I burned 30 minutes on: depending on which database driver you’re using, Stancl/Laravel may serialize a Carbon instance into JSON as either ‘2026-06-22 10:30:00’ or ‘2026-06-22T10:30:00.000000Z’. The latter looks fine, but a whereBetween against datetime bounds compares the stored value lexicographically, not as a datetime — and the uppercase T sorts higher than a space, so your bounds never match. Normalize to Y-m-d H:i:s on write to avoid the cross-database surprise. 🐘

The pattern that finally stuck

If I had to compress today into three lines:

  1. Treat tenant-data keys as model attributes, not JSON properties.
  2. Use the query builder only when you genuinely need a SQL-level filter or atomic JSON-path update.
  3. When you’re storing state about future work, store when, not if — let the database filter for you.

None of these are revolutionary. But each one took me staring at a failing test for a while before it clicked. 🎉

Posted in Laravel, PHP | Tagged , , , , | Leave a comment

Moodle Plugin Development: version.php, install.xml, upgrade.php, and tasks.php Explained

Moodle is one of the largest Learning Management System (LMS) platforms in the world, and one of its quietly excellent superpowers is its plugin architecture. Every feature you can add — a new activity, a block, a report, an authentication method, a scheduled background job — is a plugin, and every plugin follows the same handful of conventions. Once you know them, the framework gets out of your way. 🐘

What “Creating a New Module” Means in Moodle

Moodle has many plugin types, but the four most common ones you’ll touch as a backend developer are:

  • mod/ — activity modules (e.g. mod/quiz, mod/forum). Things users add to courses.
  • local/ — local plugins. Custom business logic that doesn’t fit any other plugin type. Most internal company plugins live here.
  • blocks/ — sidebar blocks shown on pages.
  • admin/tool/ — admin-side utilities and integrations.

The walkthrough below uses a local plugin called local_attendance, because local/ is the most common entry point for in-house development and it has all the same bones as the other types.

The Minimum File Layout

A working Moodle plugin needs surprisingly little. Drop this under html/local/attendance/:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local/attendance/
├── version.php          ← required: identifies the plugin and its current version
├── lang/
│   └── en/
│       └── local_attendance.php   ← language strings (required even for English)
├── db/
│   ├── install.xml      ← schema for new installs
│   ├── install.php      ← one-time seed logic after install.xml runs (optional)
│   ├── upgrade.php      ← version-gated migrations (optional but you'll want it)
│   ├── tasks.php        ← scheduled task registrations (optional)
│   └── access.php       ← capabilities/permissions (optional)
└── classes/
    └── task/
        └── send_reminders.php   ← a sample scheduled task

That’s it. Visit Site administration in your browser and Moodle will detect the new plugin, run install.xml, run install.php, and register it. No composer, no separate migration tool, no build step. ✨

version.php — the Heartbeat of Every Plugin

This file is small but load-bearing. Moodle uses it to decide whether to upgrade, downgrade-error, or do nothing on every page load:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
defined('MOODLE_INTERNAL') || die();

$plugin->component = 'local_attendance';    // frankenstyle name — type_name
$plugin->version   = 2024100100;             // YYYYMMDDXX format
$plugin->requires  = 2023100900;             // minimum Moodle core version
$plugin->maturity  = MATURITY_STABLE;        // ALPHA | BETA | RC | STABLE
$plugin->release   = 'v1.0.0';

$plugin->dependencies = [
    'mod_quiz' => 2023100900,   // optional: other plugins we depend on
];

The version number is a date-stamp integer in YYYYMMDDXX format — the XX is a daily counter, so multiple bumps on the same day still produce monotonically increasing values. Bumping this number is what triggers upgrade.php to run. If you forget to bump it, your migration code never executes.

If you bump it too aggressively and then have to roll back code, Moodle will refuse to start with a Cannot downgrade plugin from X to Y error. There’s no built-in rollback — you either roll forward (bump again) or manually edit mdl_config_plugins to lie about the installed version. More on that below.

db/install.xml — Your Schema, Declared Once

Moodle has its own XMLDB schema format. You don’t write raw SQL DDL; you write XML, and Moodle generates the right SQL for whatever database backend you’re running (MariaDB, MySQL, PostgreSQL, MSSQL, Oracle). Use the built-in editor at Site administration → Development → XMLDB editor to generate the file — hand-writing it is technically possible but error-prone.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="local/attendance/db" VERSION="20241001" COMMENT="local_attendance">
  <TABLES>
    <TABLE NAME="local_attendance_entries" COMMENT="One row per check-in">
      <FIELDS>
        <FIELD NAME="id"          TYPE="int"   LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
        <FIELD NAME="userid"      TYPE="int"   LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
        <FIELD NAME="courseid"    TYPE="int"   LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
        <FIELD NAME="status"      TYPE="char"  LENGTH="20" NOTNULL="true" SEQUENCE="false"/>
        <FIELD NAME="timecreated" TYPE="int"   LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
      </FIELDS>
      <KEYS>
        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
      </KEYS>
      <INDEXES>
        <INDEX NAME="userid_courseid_ix" UNIQUE="false" FIELDS="userid, courseid"/>
      </INDEXES>
    </TABLE>
  </TABLES>
</XMLDB>

The table gets prefixed automatically — if your site uses $CFG->prefix = ‘mdl_’, the actual table is mdl_local_attendance_entries. install.xml only runs once, the first time the plugin is installed. After that, every schema change has to go through upgrade.php. ⚠️

db/install.php — Seeding Data

If you need to seed data after the schema is created (default categories, sample rows, admin-visible config), do it here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
defined('MOODLE_INTERNAL') || die();

function xmldb_local_attendance_install() {
    global $DB;

    // Seed a couple of default attendance statuses
    $defaults = [
        ['shortname' => 'present', 'description' => 'Present'],
        ['shortname' => 'late',    'description' => 'Late'],
        ['shortname' => 'absent',  'description' => 'Absent'],
    ];

    foreach ($defaults as $row) {
        $DB->insert_record('local_attendance_statuses', (object) $row);
    }
}

Like install.xml, this runs once on first install and never again. If you need to seed data on a site that’s already running, do it from upgrade.php instead.

db/upgrade.php — Version-Gated Migrations

This is where the long-term life of your plugin happens. Every schema or data change after the initial install lives behind a version gate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
defined('MOODLE_INTERNAL') || die();

function xmldb_local_attendance_upgrade($oldversion) {
    global $DB;
    $dbman = $DB->get_manager();

    if ($oldversion < 2024100200) {
        // Add a new 'notes' column to the entries table
        $table = new xmldb_table('local_attendance_entries');
        $field = new xmldb_field('notes', XMLDB_TYPE_TEXT, null, null, null, null, null, 'status');

        if (!$dbman->field_exists($table, $field)) {
            $dbman->add_field($table, $field);
        }

        upgrade_plugin_savepoint(true, 2024100200, 'local', 'attendance');
    }

    if ($oldversion < 2024100300) {
        // Backfill a default value for any existing rows
        $DB->execute("UPDATE {local_attendance_entries} SET notes = '' WHERE notes IS NULL");

        upgrade_plugin_savepoint(true, 2024100300, 'local', 'attendance');
    }

    return true;
}

Two things to know:

  1. Every block must end with upgrade_plugin_savepoint(). This is what tells Moodle “this migration succeeded, don’t run it again” — even if a later block fails. Without it, a partial failure leaves the plugin in a wedged state on the next upgrade attempt.
  2. Migrations are not transactional across blocks. If block A succeeds and block B fails, A’s changes stay applied. Design each block to be independently completable.

Trigger the upgrade after bumping version.php:

1
2
php admin/cli/upgrade.php --non-interactive
php admin/cli/purge_caches.php

About “Rollback”

Moodle does not have a rollback command. There’s no upgrade.php down() equivalent, no Rails-style db:rollback. The Moodle philosophy is roll-forward: if you applied a bad migration, write a new migration with a higher version number that reverses or corrects the change.

When you really need to back out — for example, you’re switching branches and the new branch’s code is older than the DB — your options are:

  • Restore a DB dump taken before the upgrade. This is what production-grade teams do.
  • Manually edit mdl_config_plugins to set the plugin’s version back. Risky and only safe if you know exactly what changed:
1
2
3
UPDATE mdl_config_plugins
   SET VALUE = '2024100100'
 WHERE plugin = 'local_attendance' AND name = 'version';

The honest answer: in development, you restore dumps. In production, you only ever roll forward. 🛡️

db/tasks.php — Scheduled Jobs

Anything cron-like in Moodle is a scheduled task. Register it in tasks.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
defined('MOODLE_INTERNAL') || die();

$tasks = [
    [
        'classname' => 'local_attendance\\task\\send_reminders',
        'blocking'  => 0,
        'minute'    => '*/15',   // every 15 minutes
        'hour'      => '*',
        'day'       => '*',
        'dayofweek' => '*',
        'month'     => '*',
    ],
];

The cron fields take the standard cron syntax. The class itself lives at classes/task/send_reminders.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace local_attendance\task;

defined('MOODLE_INTERNAL') || die();

class send_reminders extends \core\task\scheduled_task {

    public function get_name(): string {
        return get_string('task_send_reminders', 'local_attendance');
    }

    public function execute() {
        global $DB;

        mtrace('Looking for users to remind...');
        $count = $DB->count_records('local_attendance_entries', ['status' => 'absent']);
        mtrace("Found {$count} absent entries.");
        // ... send emails, etc.
    }
}

Once tasks.php is in place and version.php is bumped, the next upgrade run registers the task. You can also run it manually for testing:

1
php admin/cli/scheduled_task.php --execute='\local_attendance\task\send_reminders'

For one-off background jobs (not on a recurring schedule), use adhoc tasks — same idea, but you queue them programmatically and they run once.

Putting It All Together

The lifecycle of a Moodle plugin, in five lines:

  1. Create version.php, install.xml, and a language file → first install creates the tables.
  2. Need to seed defaults? Add install.php.
  3. Change the schema later? Bump version.php and add a block to upgrade.php.
  4. Need background work? Add tasks.php and a task class under classes/task/.
  5. Run admin/cli/upgrade.php. Roll forward, never back.

Once you internalize that loop, building Moodle plugins stops feeling like CMS development and starts feeling like writing well-organized PHP modules with a particularly opinionated framework underneath. 💡

Further Reading

  • Moodle Developer Resources (canonical docs)moodledev.io. The new home of Moodle’s developer documentation since 2022; the source of truth for plugin types, APIs, and conventions.
  • Plugin types referencemoodledev.io/docs/apis/plugintypes. The exhaustive list of plugin types with required files for each.
  • XMLDB editor guidemoodledev.io/general/development/tools/xmldb. The official guide to the in-Moodle XML schema editor. Never hand-write install.xml.
  • Scheduled tasks APImoodledev.io/docs/apis/subsystems/task. Scheduled vs adhoc tasks, cron field syntax, locking, and failure handling.
  • Upgrade APImoodledev.io/docs/apis/core/upgrade. The official guide to upgrade.php, savepoints, and version handling.
  • Moodle coding stylemoodledev.io/general/development/policies/codingstyle. PHPDoc, naming, and the conventions code reviewers will check.
  • moodle-plugin-cigithub.com/moodlehq/moodle-plugin-ci. The continuous integration tooling Moodle HQ uses to validate plugins; great to wire up to your own repo for automatic lint, unit tests, and Behat runs.

Happy plugin-building. 🛠️

Posted in PHP | Tagged , , , | Leave a comment

Block Countries by IP on Debian Trixie with nftables and xtables-addons

Debian Trixie uses nftables as its default firewall. If you’re used to iptables, the commands still work — but they go through an iptables-nft compatibility shim that translates them to nftables rules under the hood. For country-based IP blocking, the cleanest approach is xtables-addons with its built-in GeoIP module. It lets you drop entire countries at the kernel level — traffic never reaches your web server. 🔐

Check Your Current Firewall

First, confirm nftables is active:

1
nft list ruleset

If you get output (even empty table blocks), you’re on nftables. If the command isn’t found, install it:

1
2
apt install nftables
systemctl enable --now nftables

Install xtables-addons and GeoIP Tools

1
apt install xtables-addons-common libtext-csv-xs-perl curl

xtables-addons provides the -m geoip match extension. The Perl module is needed by the GeoIP database download script.

Download the GeoIP Database

MaxMind is the company behind GeoLite2 — the free IP geolocation database that xtables-addons uses. MaxMind offers three GeoLite2 databases: Country, City, and ASN. For country-based blocking, you only need GeoLite2 Country — the City and ASN databases serve different purposes (detailed location data and ISP lookup, respectively) and are not used by xt_geoip_build. You need a free account to download it. There’s no credit card required.

Sign up at https://www.maxmind.com/en/geolite2/signup. After confirming your email and logging in, go to Account → Manage License Keys → Generate new license key. Copy it immediately — it’s only shown once.

The GeoIP data lives in /usr/share/xt_geoip/. You have two paths to get it there:

Path A — Manual download

Download the CSV zip directly using your license key, unzip it, then build the binary database:

1
2
3
4
5
6
7
8
9
# Download the GeoLite2 Country CSV (replace YOUR_LICENSE_KEY)
curl -O "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&license_key=YOUR_LICENSE_KEY&suffix=zip"
unzip 'GeoLite2-Country-CSV*.zip'

# Create the target directory
mkdir -p /usr/share/xt_geoip

# Build the binary database from the unzipped folder
/usr/lib/xtables-addons/xt_geoip_build -D /usr/share/xt_geoip GeoLite2-Country-CSV_*/

Path B — geoipupdate (recommended)

The geoipupdate tool handles downloads and updates automatically. Install it, then edit /etc/GeoIP.conf:

1
apt install geoipupdate

Open /etc/GeoIP.conf and set these three values (your AccountID is the number shown on the MaxMind dashboard under Account → Account Information):

1
2
3
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_LICENSE_KEY
EditionIDs GeoLite2-Country

Then run geoipupdate to fetch the database. It downloads to /usr/share/GeoIP/ by default, so you still need to convert it to the binary format xtables-addons expects:

1
2
3
4
5
geoipupdate

# Convert the downloaded .mmdb to xtables-addons binary format
mkdir -p /usr/share/xt_geoip
/usr/lib/xtables-addons/xt_geoip_build -D /usr/share/xt_geoip /usr/share/GeoIP/

How the Database Lookup Works

It helps to understand what xt_geoip_build actually does with those CSV files, and how the kernel uses the result at packet time.

The ZIP contains two key files:

  • GeoLite2-Country-Blocks-IPv4.csv — maps CIDR ranges to a numeric geoname ID. For example: 1.0.1.0/241814991
  • GeoLite2-Country-Locations-en.csv — maps each geoname ID to a two-letter ISO country code. For example: 1814991CN (China)

xt_geoip_build reads both files at build time, joins them on the geoname ID, and produces compact binary files (.iv4 and .iv6) in /usr/share/xt_geoip/. The geoname IDs are resolved away — the binary files are just sorted arrays of IP ranges, each tagged directly with a country code.

At packet time, the xt_geoip kernel module does a binary search over those ranges to find which one contains the source IP, reads the country code off that entry, and compares it against your –source-country list. No userspace process is involved — it all happens in-kernel on every packet.

The ZIP also includes location files for other languages (Locations-es.csv, Locations-zh-CN.csv, Locations-de.csv, etc.). These are pure localization — the same geoname IDs and ISO codes, just with country names translated. xt_geoip_build only needs the ISO codes so it doesn’t matter which language file it reads. The translated names exist for applications that display country names to users in their language.

Block Countries with iptables (nft shim)

Since xtables-addons plugs into the iptables extension system, use iptables syntax with the -m geoip module. The –source-country flag takes two-letter ISO country codes:

1
2
3
4
5
# Block inbound traffic from Russia, Turkey, China, North Korea
iptables -I INPUT -m geoip --source-country RU,TR,CN,KP -j DROP

# Same for IPv6
ip6tables -I INPUT -m geoip --source-country RU,TR,CN,KP -j DROP

Check it landed:

1
iptables -L INPUT -v --line-numbers

Make It Persistent Across Reboots

iptables rules don’t survive a reboot by default. Save them:

1
2
apt install iptables-persistent
netfilter-persistent save

Rules are saved to /etc/iptables/rules.v4 and /etc/iptables/rules.v6 and restored automatically on boot.

Keep the GeoIP Database Fresh

MaxMind updates GeoLite2 twice a week. Add a cron job to refresh and reload:

1
2
3
4
5
# /etc/cron.weekly/update-geoip
#!/bin/bash
geoipupdate
/usr/lib/xtables-addons/xt_geoip_build -D /usr/share/xt_geoip /usr/share/GeoIP/
netfilter-persistent reload
1
chmod +x /etc/cron.weekly/update-geoip

Quick Reference: ISO Country Codes

A few commonly blocked ones:

Country Code
Russia RU
Turkey TR
China CN
North Korea KP
Iran IR
Brazil BR

Full list at wikipedia.org/wiki/ISO_3166-1_alpha-2.

That’s it — once the rules are in place and persistent, your server silently drops packets from those regions before Apache or WordPress ever sees them. 🎉

How to Test and Validate the Rules

After setting up the rules, you want to confirm they actually work — not just that the commands ran without errors. Here are a few practical ways to validate. 🧪

1. Check the Rule Is Loaded

Confirm the geoip rule exists in the INPUT chain with hit counters:

1
iptables -L INPUT -v --line-numbers

Look for a line referencing geoip with your country codes. The pkts and bytes columns start at zero — they’ll increment as matching traffic hits the rule.

2. Simulate a Packet from a Blocked IP with xtables-addons

You can test whether a specific IP would be matched using iptables with the –source flag and a known IP from a blocked country. Pick a well-known public IP from that country (e.g. a Russian DNS server like 77.88.8.8 — Yandex DNS):

1
2
# Check if the rule matches a known Russian IP
iptables -C INPUT -s 77.88.8.8 -m geoip --source-country RU -j DROP

Exit code 0 means the rule matches. Exit code 1 means it doesn’t exist or doesn’t match.

3. Watch the Packet Counter Increment

Use watch to monitor the rule counters in real time while you simulate traffic:

1
watch -n1 'iptables -L INPUT -v --line-numbers'

In a second terminal, use hping3 to send a spoofed packet from a blocked IP range:

1
2
3
apt install hping3
# Send 5 SYN packets spoofed as coming from a Russian IP
hping3 -S -c 5 -a 77.88.8.8 localhost

Watch the pkts counter on the DROP rule increment in the first terminal. If it goes up, the rule is working.

4. Use a VPN to Test from a Blocked Country

The most realistic test: connect to a VPN exit node in one of your blocked countries (many free/trial VPNs have Russian or Turkish servers) and try to reach your server. You should get a connection timeout — not a refused connection, a timeout, because DROP silently discards the packet rather than sending a TCP RST back.

If you’d rather get a clear rejection instead of a silent drop, swap DROP for REJECT during testing — it sends an ICMP port-unreachable back, making it easier to confirm the block is working. Switch back to DROP for production (less information leakage).

5. Check xtables-addons GeoIP Lookup Directly

Verify the GeoIP database is loaded and resolves countries correctly:

1
2
3
4
5
6
# Check the database files exist
ls /usr/share/xt_geoip/

# Load the module manually if needed
modprobe xt_geoip
lsmod | grep geoip

If lsmod shows xt_geoip, the kernel module is loaded and the database is accessible.

Summary: Validation Checklist

Check Command Expected result
Rule exists iptables -L INPUT -v geoip DROP rule visible
Module loaded lsmod | grep geoip xt_geoip listed
DB files present ls /usr/share/xt_geoip/ .iv4/.iv6 files present
Packet counter watch iptables -L INPUT -v + hping3 pkts counter increments
Real-world test VPN to blocked country Connection timeout
Posted in Linux | Tagged , , , | Comments Off on Block Countries by IP on Debian Trixie with nftables and xtables-addons

How to Set Up Google Analytics 4 (GA4) — Step-by-Step Guide

If you’ve been putting off setting up Google Analytics because you’re still on the old “Universal Analytics” (UA) — it’s time to act. UA is officially dead:

  • Standard UA properties stopped collecting data on July 1, 2023
  • UA 360 (premium) stopped on July 1, 2024
  • All historical UA data has been permanently deleted

Everyone is on GA4 now. This guide walks you through setting it up from scratch. 📊

What Changed: GA4 vs. Universal Analytics

GA4 isn’t just an upgrade — it’s a different philosophy. Here’s the quick comparison:

Universal Analytics GA4
Tracking model Session-based (pageviews) Event-based (every interaction)
Views per property Up to 25 1
Cross-device tracking Limited Built-in (User ID)
IP anonymization Optional Always on
BigQuery export GA360 paid only Free
Debug tools Basic Built-in DebugView

The biggest mental shift: in GA4, everything is an event — a pageview, a click, a form submission, a purchase. There are no more “goals” — instead, you mark events as Conversions.

Step-by-Step: Setting Up GA4 from Scratch

Step 1 — Create a Google Account

You need a Google account. If you already have Gmail, you’re set. If not, go to accounts.google.com and create one.

Step 2 — Go to Google Analytics

Navigate to analytics.google.com and sign in with your Google account.

Step 3 — Create a GA4 Account

Click Admin (the gear icon, bottom-left) → CreateAccount.

Give your account a name (usually your company or website name). An Account is the top-level container — you might have one account with multiple properties underneath it (e.g. your main site + a mobile app).

Step 4 — Create a Property

A Property represents a single website or app you want to track.

  • Enter a property name (e.g. “My Website”)
  • Set your reporting timezone and currency
  • Click Create

Step 5 — Create a Data Stream

A Data Stream is where the actual data flows in from. Choose your platform:

  • Web — for a website
  • iOS app — for an iPhone/iPad app
  • Android app — for an Android app

For web: enter your website URL and a stream name, then click Create stream.

You’ll get a Measurement ID that looks like G-XXXXXXXXXX. Keep this handy — you’ll need it in the next step.

Step 6 — Install the Tracking Code

You have three options:

Option A — Manual (paste into HTML)

Copy the Google tag snippet and paste it immediately after the opening <head> tag on every page of your site:

1
2
3
4
5
6
7
8
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>

Replace G-XXXXXXXXXX with your actual Measurement ID.

Option B — Google Tag Manager

If you use GTM: create a new tag of type Google Tag, enter your Measurement ID, set it to fire on All Pages, and publish.

Option C — CMS Plugin

Most popular CMSes have native GA4 support:

  • WordPress: Site Kit by Google plugin, or insert via a header plugin
  • Shopify: Settings → Customer events → add GA4 pixel
  • Squarespace / Wix: Analytics settings → connect GA4 with your Measurement ID

Step 7 — Verify It’s Working

Back in Google Analytics, go to Reports → Real-time.

Open your website in another tab and navigate around. Within a minute or two, you should see yourself show up as an active user in the Real-time report.

If you see data: you’re done. GA4 is live. 🎉

💡 Tip: Use Admin → DebugView for more detailed real-time event validation while testing. It shows every event as it fires — great for confirming your setup before you go live.

Quick Notes

  • GA4 is free for most websites. Analytics 360 (the enterprise tier) starts at $50,000/year — skip it unless you’re a large enterprise.
  • BigQuery export is free in GA4. In UA this was a paid GA360 feature. You can now pipe raw event data to BigQuery for custom analysis.
  • You only get one data view per property in GA4 (UA had up to 25). Use filters and Explorations to segment data instead.
  • Historical UA data is gone. GA4 starts fresh — your reports won’t show data from before you installed it.

Once tracking is running, explore the Acquisition, Engagement, and Retention report sections to understand where your visitors come from and what they do on your site.

Posted in Analytics | Tagged , | Comments Off on How to Set Up Google Analytics 4 (GA4) — Step-by-Step Guide

Using PHP with Mustache: Practical Examples and Gotchas

If you want clean templates in PHP without pulling in a full framework, Mustache is a great fit. It keeps logic out of views, which forces you to prepare data in PHP first and makes templates easier to scan later. Think of it as a small habit that prevents messy presentation code from creeping in. 💡

A minimal setup is straightforward: install a Mustache implementation, create a renderer, and pass a plain array as context. Keep your template focused on placeholders and sections, then shape everything in your controller/service layer. In Mustache, {{#items}} starts a section that automatically iterates each element in items, so the inner markup behaves like a loop body per row.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
require ‘vendor/autoload.php’;

$mustache = new Mustache_Engine([
    ‘loader’ => new Mustache_Loader_FilesystemLoader(__DIR__ . ‘/templates’),
]);

$context = [
    ‘title’ => ‘Weekly report’,
    ‘items’ => [
        [‘name’ => ‘API latency’, ‘value’ => ‘120ms’],
        [‘name’ => ‘Error rate’, ‘value’ => ‘0.4%’],
    ],
    ‘hasWarnings’ => true,
];

echo $mustache->render(‘report’, $context);

1
2
3
4
5
6
7
8
9
<h2>{{title}}</h2>
<ul>
  {{#items}}
    <li>{{name}}: {{value}}</li>
  {{/items}}
</ul>
{{#hasWarnings}}
  <p>Please review warning metrics.</p>
{{/hasWarnings}}

Gotcha #1: Mustache is logic-less on purpose. No complex conditionals, no inline calculations, and no arbitrary PHP in templates. If you feel stuck, that is usually a signal to move transformation logic into PHP before render. Gotcha #2: escaping behavior matters — {{value}} is escaped by default, while triple braces are unescaped. Only use unescaped output for trusted HTML; otherwise you can introduce XSS risk quickly. 🔐

1
2
3
4
5
6
7
8
<?php
$context = [
    ‘safe’ => ‘<strong>Hello</strong>’,
    ‘unsafe’ => ‘<script>alert(1)</script>’,
];

$template = ‘{{safe}} | {{{safe}}} | {{{unsafe}}}’;
echo $mustache->render($template, $context);

Gotcha #3: naming drift between context keys and template variables can silently render blanks. I usually keep small view-model builders (or DTOs) so key names are consistent across pages. If you keep templates dumb, context explicit, and escaping intentional, PHP + Mustache stays simple and maintainable for a long time. 🎉

Posted in PHP | Tagged , | Comments Off on Using PHP with Mustache: Practical Examples and Gotchas

Scribe for Laravel: API Docs That Stay Fresh, and a Calm Way to Upgrade Them

Most Laravel teams reach a point where their API documentation is either out of date, written somewhere it shouldn’t be (Confluence, anyone?), or just doesn’t exist. Scribe is the package that quietly fixes this — it reads your routes, controllers, and docblocks, and turns them into a polished, browsable docs page. Less work for you, fresher docs for whoever consumes your API. 🎉

What Scribe actually does

You annotate controllers with familiar phpDoc tags — @group, @urlParam, @queryParam, @responseFile — and Scribe extracts everything into intermediate YAML files under .scribe/. Those YAMLs are your editable source of truth: you can hand-tweak descriptions, add example values, mark endpoints deprecated, and so on. Scribe then renders them into a Blade view (or static HTML, your choice) that ships with your app.

A typical controller looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * @group Campaign Management
 *
 * APIs for creating and managing campaigns.
 */

class CampaignController extends Controller
{
    /**
     * Retrieve All Campaign Data (Paginated).
     *
     * @queryParam page integer Current page number. Example: 1
     * @queryParam per_page integer Items per page (max 100). Example: 10
     * @queryParam filter[status] Filter by status. Enum: draft, scheduled, in_progress, ended.
     * @responseFile storage/responses/campaigns.index.json
     */

    public function index(Request $request) { /* ... */ }
}

That’s it. The first paragraph of the docblock becomes the title; the rest is the description. @responseFile points to a JSON fixture so your example responses don’t depend on a live database during doc generation. 💡

How scribe:generate works

One command does the whole job:

1
php artisan scribe:generate

Behind the scenes, this is a two-phase pipeline:

  1. Extraction. Scribe walks your routes, parses each controller’s docblocks, and writes .scribe/endpoints.cache/*.yaml (its internal source of truth) and .scribe/endpoints/*.yaml (the user-overridable copies). If you’ve manually edited the non-cache files, Scribe respects your edits on the next run.
  2. Rendering. Scribe takes the YAML, applies its template, and writes the docs. With type: laravel, you get resources/views/scribe/index.blade.php served via a normal Laravel route — usually something like /api/v1/docs behind your auth middleware. With type: static, you get a self-contained HTML bundle in public/docs/.

The neat part: .scribe/ is meant to be checked into git. Every regenerate produces a diff you can review. If a teammate’s PR changes a route’s signature, that diff shows up in code review. Documentation drift becomes visible.

Upgrading Scribe without breaking your docs page

Recently I worked through an upgrade from Scribe 5.1 to 5.9 on a real project. The trigger was a Composer warning that nobody had been able to silence:

1
2
Package spatie/data-transfer-object is abandoned, you should avoid using it.
Use spatie/laravel-data instead.

The first instinct was to migrate from spatie/data-transfer-object to spatie/laravel-data. But a quick grep across the codebase showed something interesting: nothing in the app actually used Spatie\DataTransferObject. Zero imports, zero extends. The package was a transitive dependency — pulled in by Scribe itself.

Composer prints abandonment notices for every package in the resolved tree, including transitives. So the right fix wasn’t to migrate our code; it was to upgrade Scribe to a version that had dropped the abandoned dependency. A bit of digging through GitHub tags revealed that Scribe 5.4.0 (released October 2025) was the first release to remove it.

The actual upgrade — and the safeguard

Once you know which version to target, the upgrade itself is one command:

1
composer require --dev "knuckleswtf/scribe:^5.4" --with-all-dependencies

The –dev flag matters because Scribe lives in require-dev; without it, Composer would happily move the package to require. –with-all-dependencies lets Composer bump anything Scribe depends on. The lockfile gets rewritten cleanly; no composer install needed afterward.

But the real question is: does the upgrade break anything? This is where the committed .scribe/ directory pays you back. The whole verification dance is:

1
2
3
4
5
6
7
8
9
10
11
# Make sure your baseline is committed first
git status .scribe/

# Upgrade Scribe
composer require --dev "knuckleswtf/scribe:^5.4" --with-all-dependencies

# Regenerate
php artisan scribe:generate

# The diff is your safeguard
git diff .scribe/

In my case, the diff was almost entirely schema additions: every endpoint and parameter gained a deprecated: false field, and the Blade template’s JS asset bumped from theme-default-5.1.0.js to theme-default-5.9.0.js. No endpoints disappeared, no parameter descriptions got mangled, no response examples changed. A clean upgrade. ✅

The smoke test that catches what diffs miss

YAML diffs tell you about extracted data. They don’t tell you what an actual API consumer sees. After every Scribe upgrade, open the live docs URL in a browser:

  1. Does the page render at all?
  2. Is the base URL correct? (If you’ve configured a placeholder like https://mydomain.com and rewrite it at runtime to the tenant host, this is where you find out the rewrite still works.)
  3. Do the example curl commands look right?
  4. Does the auth section show the bearer scheme you configured?

This three-minute check catches everything the YAML diff can’t see — Blade template breakage, missing CSS assets, broken navigation. If the page loads and the example requests look sane, you’re done.

Worth knowing

A few things I’d tell my past self before starting:

  • Commit .scribe/ to git. Treat it like a build artifact you want diffable. Without that baseline, you can’t tell whether a regeneration changed anything meaningful.
  • Faker can introduce noise. Parameters without an explicit Example: value get random Faker output, which differs every run. If your diffs are noisy across regenerations even without a Scribe upgrade, that’s the cause. Pin examples in your docblocks for stable diffs.
  • Packagist’s API can lie. When researching which Scribe version dropped a dependency, I found Packagist’s v2 endpoint serving stale require data for some tags. The authoritative source is the actual composer.json in the GitHub tag — https://raw.githubusercontent.com/knuckleswtf/scribe/<tag>/composer.json.

Scribe is one of those packages that quietly removes a class of recurring chores from your day. Stale docs, undocumented endpoints, the awkward Confluence page nobody updates — all gone, replaced by something that lives next to the code and gets regenerated as part of your normal workflow. And when it’s time to upgrade, the same .scribe/ directory that powers your docs becomes the thing that tells you whether the upgrade was safe. Boring. Useful. Exactly what you want from a tool. 🐘

Posted in Laravel, PHP | Tagged , , | Comments Off on Scribe for Laravel: API Docs That Stay Fresh, and a Calm Way to Upgrade Them

Three Years of the AI Boom: The Stocks That Ran

I have been watching the AI boom unfold for the last three years and figured I should write down what I am seeing before the dust settles. Future-me will thank present-me for the bookmark.

What kicked it off

ChatGPT landed in late 2022. By early 2023 every serious tech company was scrambling to ship something with “AI” stamped on it. The capital markets noticed, and a small group of stocks took off like nothing I had seen in years.

The biggest movers

  • NVIDIA (NVDA) — the obvious one. Their GPUs became the picks-and-shovels of the AI gold rush. Roughly an 11x move since the start of 2023.
  • Palantir (PLTR) — quietly the biggest winner of all. Their AIP platform caught on with enterprise and government clients, and the stock is up around 23x since 2023.
  • Broadcom (AVGO) — custom AI silicon for hyperscalers like Google and Meta. Crossed a trillion-plus market cap on the strength of that business.
  • TSMC (TSM) — manufactures basically every advanced AI chip on the planet. The picks-and-shovels of the picks-and-shovels.
  • AMD (AMD) — the credible second source for AI accelerators. The MI300 line gave hyperscalers a reason to diversify away from NVIDIA.
  • Microsoft (MSFT) — bought a front-row seat via OpenAI and turned Azure into the default place to run frontier models.
  • Meta (META) — not a pure AI play, but their open-weights Llama strategy and ad-targeting wins re-rated the stock dramatically.

What I take away from it

Two patterns keep showing up. First, infrastructure beat applications: the companies selling chips, foundries, and cloud capacity printed money before most application-layer startups had a working business model. Second, the winners traded at valuations that looked insane the whole way up, and going up anyway. That is uncomfortable but worth remembering.

I am not making predictions about 2026 and beyond. I just want to remember what the past three years actually looked like, so when the next cycle starts I have a reference point.

Posted in Uncategorized | Tagged , , | Comments Off on Three Years of the AI Boom: The Stocks That Ran