Extension Points
Extensions interact with Total CMS through the ExtensionContext object passed to register() and boot(). This page covers every available extension point.
Naming Conventions
To avoid collisions with core T3 functions and other extensions, always prefix your Twig functions, filters, and CLI commands with your vendor name:
- Twig functions:
vendor_functionname(e.g.acme_seo_title) - Twig filters:
vendor_filtername(e.g.acme_readinglevel) - CLI commands:
vendor:commandname(e.g.acme:generate-sitemap)
If a name collision is detected at boot time, a warning is logged to extensions.log and the last registered function/filter wins. Prefixing with your vendor name prevents this.
Twig rendering model
Total CMS renders templates in streaming (yield) mode — the same mode Twig 4 uses exclusively. This is transparent to extensions, with one rule: your Twig functions and filters must return their output. Never echo, print, or open an output buffer (ob_start()) inside a function or filter — in streaming mode anything written directly to output bypasses the template stream and lands in the wrong place (or is lost entirely). Build a string and return it.
// ✅ Correct — return the value; Twig places it in the stream
new TwigFunction('acme_banner', fn (): string => '<div class="banner">…</div>');
// ❌ Wrong — echo bypasses the render stream under yield mode
new TwigFunction('acme_banner', function (): void {
echo '<div class="banner">…</div>';
});
Extensions register Twig functions, filters, and globals — never custom tags or nodes — so there is nothing else to do to be yield-ready.
Twig Functions
Add custom functions available in all Twig templates.
use Twig\TwigFunction;
public function register(ExtensionContext $context): void
{
$context->addTwigFunction(
new TwigFunction('seo_title', function (string $title, ?string $suffix = null): string {
return $suffix ? "{$title} | {$suffix}" : $title;
})
);
}
Capability: twig:functions
Usage in templates:
<title>{{ seo_title(post.title, 'My Site') }}</title>
Twig Filters
Add custom filters for transforming values in templates.
use Twig\TwigFilter;
public function register(ExtensionContext $context): void
{
$context->addTwigFilter(
new TwigFilter('reading_level', function (string $text): string {
$words = str_word_count($text);
return match (true) {
$words < 100 => 'Quick read',
$words < 500 => 'Medium read',
default => 'Long read',
};
})
);
}
Capability: twig:filters
Usage in templates:
<span class="reading-level">{{ post.content|reading_level }}</span>
Twig Globals
Add global variables accessible in all templates.
public function register(ExtensionContext $context): void
{
$context->addTwigGlobal('seo', new SeoHelper());
}
Capability: twig:globals
Usage in templates:
{{ seo.metaTags(post) }}
CLI Commands
Register custom commands for the tcms CLI tool. Commands must use namespaced names (vendor:command).
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
public function register(ExtensionContext $context): void
{
$context->addCommand(new class extends Command {
protected function configure(): void
{
$this->setName('acme:generate-sitemap');
$this->setDescription('Generate an XML sitemap');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Sitemap generated.');
return Command::SUCCESS;
}
});
}
Capability: cli:commands
Usage:
tcms acme:generate-sitemap
Routes
Extensions can register three types of routes, each with different authentication and URL prefixes.
API Routes
Register authenticated API routes under /ext/{vendor}/{name}/.
use Slim\Routing\RouteCollectorProxy;
public function register(ExtensionContext $context): void
{
$context->addRoutes(function (RouteCollectorProxy $group): void {
$group->get('/status', StatusAction::class);
$group->post('/analyze', AnalyzeAction::class);
});
}
Capability: routes:api
The routes above are accessible at:
/ext/acme/seo-pro/status/ext/acme/seo-pro/analyze
Admin Routes
Register routes under /admin/ext/{vendor}/{name}/. These routes require a logged-in dashboard user and are super-admin only by default. Templates can extend admin-dashboard.twig for the admin layout.
use Slim\Routing\RouteCollectorProxy;
public function register(ExtensionContext $context): void
{
$context->addAdminRoutes(function (RouteCollectorProxy $group): void {
$group->get('/dashboard', MyDashboardAction::class);
$group->get('/settings', MySettingsAction::class);
// Open a page to every logged-in dashboard user (not just admins)
$group->get('/reports', MyReportsAction::class, permission: 'any');
});
}
Capability: routes:admin
The routes above are accessible at:
/admin/ext/acme/seo-pro/dashboard/admin/ext/acme/seo-pro/settings
Pass permission: 'any' per route to open a page to non-admin dashboard users; anything else means admin-only. Set the same value on the admin navigation item that links to the page — the nav permission controls who sees the link, the route permission controls who can load the page.
Operators have the final say over 'any' surfaces: each access group has an Extension Access list (Utilities → Access Groups) that controls which extensions' nav items, widgets, and 'any' pages its members can see and open. Groups default to all-extensions-granted; admin pages are unaffected (super admins only, always).
Public Routes
Register unauthenticated routes under /ext/{vendor}/{name}/. These routes have no authentication — use for webhooks, embeds, and endpoints that must be accessible without credentials.
use Slim\Routing\RouteCollectorProxy;
public function register(ExtensionContext $context): void
{
$context->addPublicRoutes(function (RouteCollectorProxy $group): void {
$group->post('/webhook', WebhookAction::class);
$group->get('/embed/{id}', EmbedAction::class);
});
}
Capability: routes:public
The routes above are accessible at:
/ext/acme/seo-pro/webhook/ext/acme/seo-pro/embed/{id}
Admin Navigation
Add items to the admin sidebar.
use TotalCMS\Domain\Extension\Data\AdminNavItem;
public function register(ExtensionContext $context): void
{
$context->addAdminNavItem(new AdminNavItem(
label: 'SEO Pro',
icon: 'seo',
url: '/ext/acme/seo-pro/dashboard',
permission: 'admin',
priority: 50,
));
}
Capability: admin:nav
The priority field controls ordering (lower numbers appear first). The permission field controls visibility (admin = admin users only).
Dashboard Widgets
Add widgets to the admin home screen.
use TotalCMS\Domain\Extension\Data\DashboardWidget;
public function register(ExtensionContext $context): void
{
$context->addDashboardWidget(new DashboardWidget(
id: 'seo-score',
label: 'SEO Score',
template: 'widgets/seo-score.twig',
position: 'main',
priority: 30,
));
}
Capability: admin:widgets
The template path is relative to the extension's templates/ directory. Position is main or sidebar.
Custom Field Types
Register new field types for use in collection schemas.
public function register(ExtensionContext $context): void
{
$context->addFieldType('colorpicker', Acme\Fields\ColorPickerField::class, 'color');
}
Capability: fields
The class must extend TotalCMS\Domain\Admin\FormField\FormField. The third
argument declares the default schema property type used when an author leaves
the property's type blank — should be one of SchemaData::PROPERTY_TYPES
(e.g. string, color, array). Defaults to string if omitted.
Once registered, the field type can be used in schemas:
{
"properties": {
"accentColor": {
"field": "colorpicker"
}
}
}
Event Listeners
Subscribe to content events. See Events for the full event reference.
public function register(ExtensionContext $context): void
{
$context->addEventListener('object.created', function (array $payload): void {
// $payload contains: collection, id
$this->notifyWebhook($payload);
});
}
Capability: events:listen
Automations
Ship a server-side automation as part of your extension. Like the user-authored automations in the admin, an extension automation runs on a schedule or a content event — but its handler is the closure you register here, held in memory (never written to disk). The handler receives an AutomationContext with pre-injected services (objectFetcher, objectSaver, objectUpdater, objectRemover, indexReader, mailer, config, logger) plus the run payload (trigger, args, event).
public function register(ExtensionContext $context): void
{
$context->addAutomation(
id: 'prune-drafts',
label: 'Prune stale drafts',
triggers: [
['type' => 'schedule', 'cron' => '0 3 * * *'], // daily at 03:00
// ['type' => 'event', 'event' => 'object.created', 'collection' => 'members'],
],
handler: function (\TotalCMS\Domain\Automation\Data\AutomationContext $ctx): array {
$ctx->logger->info('[acme/starter] prune-drafts tick');
// ... do work via $ctx->objectFetcher / $ctx->objectRemover ...
return ['pruned' => 0];
},
);
}
Extension automations appear in the automations admin read-only — operators can run or disable them (via the capability toggle) but cannot edit the closure, since it lives in your code. Returning a value records it on the run record; throwing marks the run failed.
For HTTP triggers, register a public route rather than a webhook trigger — an extension automation's id (vendor/name:id) isn't a URL path, so webhook triggers stay reserved for user-authored automations.
Capability: automations
Container Definitions
Register services in the DI container for dependency injection.
public function register(ExtensionContext $context): void
{
$context->addContainerDefinition(
SeoAnalyzer::class,
fn () => new SeoAnalyzer()
);
}
Register services under your own vendor namespace. Core service IDs cannot be overridden — a definition whose ID belongs to Total CMS (the TotalCMS\ namespace or any service the core container already defines) is skipped with a warning in extensions.log. The rest of your extension still loads.
Capability: container — always-on infrastructure: it is applied whenever the extension is enabled and never appears as a permission toggle, since your other capabilities (routes, page middleware, Twig functions) resolve against these services.
Page Middleware
Register middleware that builder pages can opt into via their middleware field. Useful for auth gates, rate limits, geo redirects, A/B splits, or anything else that needs to run before a page renders. The middleware can short-circuit (return a response — auth redirect, 429, etc.) or pass through (return null) and let the page render.
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TotalCMS\Domain\Builder\Data\PageData;
use TotalCMS\Domain\Builder\PageMiddleware\PageMiddlewareInterface;
class GeoRedirect implements PageMiddlewareInterface
{
public function __construct(private readonly GeoIPService $geo) {}
public function handle(ServerRequestInterface $request, PageData $page): ?ResponseInterface
{
if ($this->geo->countryFor($request) === 'EU') {
return (new \Nyholm\Psr7\Factory\Psr17Factory())
->createResponse(302)
->withHeader('Location', '/eu' . $request->getUri()->getPath());
}
return null;
}
}
public function register(ExtensionContext $context): void
{
$context->addContainerDefinition(GeoRedirect::class, fn ($c) => new GeoRedirect(
$c->get(GeoIPService::class),
));
$context->addPageMiddleware('geo-redirect', GeoRedirect::class);
}
Once registered, geo-redirect shows up in the page form's middleware multiselect. Admins choose which pages use it; the runner invokes it in the order listed on each page.
Naming: lowercase letters, digits, hyphens (e.g. geo-redirect, staff-only, rate-limit). Names are stable contract — once shipped, a rename will break sites that have it in their page records.
Failure modes:
- An unknown name in a page's middleware list (typo, uninstalled extension) is logged and silently skipped.
- A middleware that throws causes the runner to return a 500 — fail-closed for security.
Capability: page-middleware
See the Page Middleware section in the Builder overview for the user-facing perspective.
Form Actions
Register custom form action types that fire after a form save. The JavaScript form processor dispatches the action to an extension-owned API route — no core JS changes needed per provider.
use TotalCMS\Domain\Extension\Data\FormAction;
public function register(ExtensionContext $context): void
{
// Register the action type — tells core "slack" is a valid form action
$context->addFormAction('slack', new FormAction(
name: 'slack',
route: '/ext/acme/slack-notify/send',
label: 'Slack Notification',
));
// Register the API route that handles the action
$context->addRoutes(function ($group): void {
$group->post('/send', SlackNotifyAction::class);
});
}
Capability: form-actions
Edition requirement: Extension form actions require the Pro edition. On lower editions, actions matching extension-registered types are silently filtered from the form's action list.
The action handler receives a POST with { data: <formData>, ...actionConfig } — the form data plus all properties from the action's JSON config. For example, a collection's .meta.json might define:
{
"formSettings": {
"newActions": [
{
"action": "slack",
"channel": "#orders",
"message": "New order from {{ data.name }}"
}
]
}
}
The handler receives data (the saved form fields), channel, message, and any other properties the operator configured. Parse what you need, ignore the rest.
Naming: Use your vendor name or a distinctive slug (e.g. slack, discord, ntfy). The name appears in collection action configs and must be stable once shipped.
See the bundled Pushover extension for a complete working example.
Assets (CSS / JS)
Extensions can register CSS or JavaScript files for the admin interface and/or public pages. Each surface has its own registration method and capability:
public function register(ExtensionContext $context): void
{
// Admin interface only
$context->addAdminAsset('css', 'styles/admin.css');
$context->addAdminAsset('js', 'scripts/admin.js');
// Public pages only
$context->addFrontendAsset('css', 'styles/widget.css');
$context->addFrontendAsset('js', 'scripts/widget.js');
}
Capabilities: admin:assets, frontend:assets
Paths are relative to the extension's assets/ directory. For example, the CSS file above would resolve to tcms-data/extensions/acme/seo-pro/assets/styles/admin.css and be served from /ext/acme/seo-pro/assets/styles/admin.css with an mtime-based cache-busting query string.
Optional parameters
Both methods accept the same set of options:
$context->addFrontendAsset(
type: 'js',
path: 'scripts/widget.js',
position: 'body', // 'head' | 'body' | null — null uses the default
module: true, // load as <script type="module"> (default true)
preload: true, // emit a <link rel="modulepreload"> hint in the head
version: null, // override the cache-bust query string (default: file mtime)
);
Defaults: CSS goes in the head, JS goes in the body, module scripts emit type="module", no preload, mtime-based cache busting.
Where they render
Extension assets are merged with Total CMS core assets and emitted by these Twig helpers in your templates:
| Surface | Helper | Typical placement |
|---|---|---|
| Public pages | {{ cms.assetsHead() }} |
inside <head> |
| Public pages | {{ cms.assetsBody() }} |
just before </body> |
| Admin pages | {{ cms.adminAssetsHead() }} |
inside <head> (already wired by core admin templates) |
| Admin pages | {{ cms.adminAssetsBody() }} |
just before </body> (already wired) |
For the admin interface there's nothing to do — core admin templates already call the helpers. For public pages, your theme template needs to call cms.assetsHead() / cms.assetsBody() for extension frontend assets to render.
Within each helper, output ordering is:
- Stylesheets first.
- Preload hints (
<link rel="preload">/<link rel="modulepreload">) — always emitted in the head regardless of the asset's ownposition. - Script tags last.
Settings
Read per-extension settings. Settings are stored in tcms-data/.system/extension-settings/.
public function boot(ExtensionContext $context): void
{
$apiKey = $context->setting('api_key', '');
$allSettings = $context->settings();
}
Settings access is not a separate capability — $context->setting() and $context->settings() are always available regardless of permission state.
Define a settings_schema in your manifest to enable a settings form in the admin UI. Settings are managed by admins through the extension settings page in the dashboard.
File Storage
When your extension needs to persist files — generated secrets, caches, state — use the storage API instead of raw file functions:
public function register(ExtensionContext $context): void
{
$storage = $context->storage();
$storage->write('state.json', json_encode($state)); // creates directories as needed
$data = $storage->read('state.json'); // null when missing
$exists = $storage->exists('state.json');
$storage->delete('state.json');
$path = $storage->path('state.json'); // absolute path, e.g. for streaming
}
Files land in tcms-data/.system/extension-data/{vendor}/{name}/ — protected behind the web server's deny rules, excluded from version control, and safe across application updates. Directories are created 0700 and files 0600. Paths are relative to your extension's directory; absolute paths and .. traversal throw.
write() throws when the datadir isn't writable — let it propagate from register()/boot() so the failure is recorded as a visible extension error instead of a silent no-op.
Prefer this over file_put_contents(): raw file writes in your source are flagged on the pre-enable review screen (they're unconstrained — the operator deserves a heads-up), while storage API calls are not. Storage access is not a separate capability — like settings, it is always available.
Service Resolution (Boot Phase)
During boot(), resolve any service from the DI container:
public function boot(ExtensionContext $context): void
{
$config = $context->get(\TotalCMS\Support\Config::class);
$cache = $context->get(\TotalCMS\Domain\Cache\CacheManager::class);
}
Only use $context->get() in boot(), never in register(). The container is not fully built during registration.
Logging
Extensions can write to the shared extensions.log file (tcms-data/.system/logs/extensions.log) using the same logger Total CMS uses internally. Get it from the context with $context->logger() — it returns a Psr\Log\LoggerInterface.
public function register(ExtensionContext $context): void
{
$logger = $context->logger();
$context->addEventListener('object.created', function (array $payload) use ($logger): void {
$logger->info('[acme/starter] object.created', $payload);
});
}
public function boot(ExtensionContext $context): void
{
$context->logger()->debug('[acme/starter] booted');
}
PSR-3 levels are available: debug, info, notice, warning, error, critical, alert, emergency. Pass a context array as the second argument for structured fields — Monolog appends them to the formatted line.
Logger access is not a separate capability — $context->logger() is always available, in both register() and boot().
Prefix log messages with your extension id (e.g. [acme/starter]) so multi-extension logs stay readable. All extensions share the extensions channel and the same rotating log file.
MCP Prompts
Register prompts that the MCP server exposes to AI agents via prompts/list and prompts/get. Use when the extension ships prompts as PHP code rather than relying on operator-authored objects in the mcp-prompt collection.
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Prompt;
public function register(ExtensionContext $context): void
{
$context->registerMcpPrompt(
new Prompt(
name: 'acme_audit_links',
description: 'Audit broken links on any page.',
arguments: [
new \Mcp\Schema\PromptArgument('url', 'The URL to audit', required: true),
],
),
handler: fn (array $arguments = []): array => [
new PromptMessage(
Role::User,
new TextContent('Check all links on: ' . ($arguments['url'] ?? '')),
),
],
access: 'admin',
);
}
Capability: mcp:prompts
Access: The optional third argument $access sets visibility — 'admin' (default), 'authenticated', or 'public'. The default keeps prompts private to admin-persona callers. Choose 'public' only when the prompt body contains no site-private data.
Collision policy: If a prompt name collides with a collection-stored prompt, the collection-stored version wins — the extension's prompt is logged and skipped.
Search Providers
Register a custom search provider that replaces or supplements T3's built-in text search. The provider handles indexing (on object create/update/delete events) and querying (from MCP search tools, future REST endpoints, or site-wide search).
use TotalCMS\Domain\Search\Service\SearchProvider;
use TotalCMS\Domain\Search\Data\SearchQuery;
use TotalCMS\Domain\Search\Data\SearchResult;
class MySearchProvider implements SearchProvider
{
public function id(): string { return 'my-search'; }
public function label(): string { return 'My Search'; }
public function search(SearchQuery $query): array
{
// Query your search backend, return list<SearchResult>
return [];
}
public function index(string $collection, string $id, array $data): void
{
// Index one object in your backend
}
public function delete(string $collection, string $id): void
{
// Remove one object from your backend
}
public function isAvailable(): bool
{
// Fast health check — cache with short TTL (on the hot path)
return true;
}
}
public function register(ExtensionContext $context): void
{
$context->registerSearchProvider(new MySearchProvider());
}
Capability: mcp:search
Provider ids must be unique across all extensions + the built-in text provider. The registrar logs and skips collisions during boot. When isAvailable() returns false, SearchService silently falls back to text search. Throwing from search() also triggers the fallback. Throwing from index() or delete() enqueues a retry job.
See the bundled Algolia Search extension for a complete working example.