Extend the CMS

Add your own code without touching a core file

An extension registers what it provides — Twig functions, CLI commands, admin pages, API routes, field types, event listeners — through a stable context object. A broken one cannot take the site down.

An extension is a folder with a manifest and an entry-point class. It implements ExtensionInterface and talks to Total CMS through an ExtensionContext — a stable, versioned API — in two phases: register() declares what the extension provides, and boot() wires it into the running application once the container is built.

What it can provide covers most of what you would otherwise fork core to do: Twig functions, filters and globals; tcms CLI commands; API, admin and public routes; admin sidebar items and dashboard widgets; custom field types usable in any schema; event listeners on content events; page middleware that Site Builder pages opt into; form actions; MCP prompts and search providers; admin and frontend CSS/JS; container definitions; and scheduled or event-driven automations whose handler is a closure in your code. Each point has its own capability name, and capabilities appear in the admin as permissions an operator can switch off individually without uninstalling anything.

Where an extension lives decides who owns it. Site-specific ones belong in an extensions/ directory at the project root, next to tcms-data/ — that is code, so it belongs in your git repo and deploys with a git pull. Distributed ones install into tcms-data/extensions/{vendor}/{name}/. Total CMS also ships bundled extensions with every install, disabled by default. If the same id exists in more than one place the most specific copy wins, and the shadowing is logged.

Safety is not left to the author. Every place extension code runs during a request is wrapped, so an exception produces nothing instead of a white screen: it is logged to extensions.log, recorded in the extension's state for the admin to show, and every other extension carries on loading. On a production site an extension that crashes repeatedly is auto-quarantined — disabled for you, with a one-click re-enable — while a development site keeps it enabled and loud so you can debug it.

You can also see what each one costs. Total CMS samples request timings and shows the average and most recent cost per extension on the Extensions page, with configurable warnings when one extension, or all of them together, gets heavy. Manage everything from Settings → Extensions or the CLI: tcms extension:list, enable, disable, remove.

What you get

One context, many extension points

Twig functions and filters, CLI commands, routes, admin pages, widgets, field types, event listeners, page middleware, search providers and more — all through ExtensionContext.

Permissions per capability

Each extension point has a capability name, and an operator can switch individual capabilities off from the extension's settings page without uninstalling it.

A broken extension cannot crash the site

Exceptions during register, boot or a request are caught, logged to extensions.log and shown in the admin. Every other extension keeps loading.

Auto-quarantine on production

An extension that crashes repeatedly on a production site is disabled automatically, with a banner and a one-click re-enable. Development sites stay loud instead.

Project extensions live in git

Put a site-specific extension in an extensions/ folder at the project root and it is code you commit and deploy, not content sitting inside tcms-data.

In practice

One snippet

<?php

namespace Acme\Hello;

use TotalCMS\Domain\Extension\ExtensionContext;
use TotalCMS\Domain\Extension\ExtensionInterface;
use Twig\TwigFunction;

class Extension implements ExtensionInterface
{
    public function register(ExtensionContext $context): void
    {
        $context->addTwigFunction(
            new TwigFunction('hello', fn (string $name): string => "Hello, {$name}!")
        );
    }

    public function boot(ExtensionContext $context): void
    {
        // Nothing to do on boot for this one
    }
}

That is a whole working extension: a manifest beside it, then {{ hello('World') }} works in every template on the site.

FAQ

Common questions

What happens if an extension has a bug?

The exception is caught and logged to extensions.log, recorded in the extension's state so the admin can show it, and the rest of the site keeps working. On production, an extension that crashes repeatedly is disabled automatically until you re-enable it.

Can I stop an extension doing one particular thing?

Yes. Each extension point has a capability, and capabilities show up as individual permission toggles on the extension's settings page — so you can leave an extension enabled while switching off, say, its automations.

Does an extension need a particular edition?

An extension can declare a min_edition of lite, standard or pro in its manifest, and one requiring more than the site's license is not loaded. Two things are Pro regardless: schemas provided by an extension, and extension-registered form actions.

How do I start writing one?

Clone the extension-starter repository into your extensions directory — it is a working extension demonstrating every extension point — then run tcms extension:enable your-vendor/your-extension.